diff --git a/.github/ci/apple-checks.json b/.github/ci/apple-checks.json index 262c89945c4..9a851f2ef3f 100644 --- a/.github/ci/apple-checks.json +++ b/.github/ci/apple-checks.json @@ -43,7 +43,7 @@ "!maven/cn1-applovin/**", "!maven/cn1-unity-levelplay/**", "!maven/core-unittests/**", - "vm/backend/demo/cn1ss/**", + "scripts/hellocodenameone/backend/**", "scripts/lib/cn1ss.sh" ], "push": [ @@ -89,7 +89,7 @@ "!maven/cn1-applovin/**", "!maven/cn1-unity-levelplay/**", "!maven/core-unittests/**", - "vm/backend/demo/cn1ss/**", + "scripts/hellocodenameone/backend/**", "scripts/lib/cn1ss.sh" ] }, @@ -234,7 +234,7 @@ "!maven/cn1-unity-levelplay/**", "!maven/core-unittests/**", "!docs/**", - "vm/backend/demo/cn1ss/**" + "scripts/hellocodenameone/backend/**" ], "push": [ ".github/workflows/scripts-mac-catalyst.yml", @@ -262,7 +262,7 @@ "!maven/cn1-applovin/**", "!maven/cn1-unity-levelplay/**", "!maven/core-unittests/**", - "vm/backend/demo/cn1ss/**" + "scripts/hellocodenameone/backend/**" ] } } diff --git a/.github/scripts/build_javadocs.sh b/.github/scripts/build_javadocs.sh index 5035acba99b..a7a1e67718d 100755 --- a/.github/scripts/build_javadocs.sh +++ b/.github/scripts/build_javadocs.sh @@ -155,6 +155,13 @@ rm -rf "$BACKEND_STAGE" "$CN1_DIR/dist/backend-javadoc" "$CN1_DIR/backend-javado mkdir -p "$BACKEND_STAGE" "$CN1_DIR/dist/backend-javadoc" cp -r "$BACKEND_DIR/src/." "$BACKEND_STAGE/" cp -r "$BACKEND_DIR/impl/parparvm/." "$BACKEND_STAGE/" +# The test library, codenameone-backend-test: its public package is the API a +# backend's tests are written against, so it is documented here. Its translation +# stubs stand in for the JUnit extension, and the JUnit subset it compiles against +# is on the source path only, to resolve @ExtendWith -- JUnit is not ours to +# document. +cp -r "$BACKEND_DIR/test/src/." "$BACKEND_STAGE/" +cp -r "$BACKEND_DIR/test/parparvm/." "$BACKEND_STAGE/" while IFS= read -r shared; do rel="${shared#"$CN1_DIR/src/"}" mkdir -p "$BACKEND_STAGE/$(dirname "$rel")" @@ -201,7 +208,7 @@ python3 "$ROOT_DIR/scripts/check-backend-jdk-surface.py" --add-script "$ROOT_DIR/maven/javadoc-resources/highlight.min.js" \ --add-script "$ROOT_DIR/maven/javadoc-resources/javadoc-highlight-init.js" \ --release 8 \ - -sourcepath "$BACKEND_STAGE:$ROOT_DIR/Ports/CLDC11/src:$ROOT_DIR/vm/JavaAPI/src" \ + -sourcepath "$BACKEND_STAGE:$ROOT_DIR/Ports/CLDC11/src:$ROOT_DIR/vm/JavaAPI/src:$BACKEND_DIR/test/junit-shim" \ -Xdoclint:all,-missing \ -Xmaxerrs 10000 \ -Xmaxwarns 10000 \ @@ -220,8 +227,17 @@ if [ -e "$CN1_DIR/dist/backend-javadoc/com/codename1/impl" ]; then echo "Backend JavaDoc generated com.codename1.impl output; the internal package must stay excluded." >&2 exit 1 fi +"$ROOT_DIR/scripts/check-backend-javadoc-internals.sh" "$CN1_DIR/dist/backend-javadoc" html # The shared classes are the point of staging the core sources at all; a run # that lost them would publish a backend ORM with no Session to call. +if [ ! -f "$CN1_DIR/dist/backend-javadoc/com/codename1/backend/test/BackendTest.html" ]; then + echo "Backend JavaDoc is missing the test library, com.codename1.backend.test; aborting." >&2 + exit 1 +fi +if [ -e "$CN1_DIR/dist/backend-javadoc/org/junit" ]; then + echo "Backend JavaDoc documented the JUnit subset; it is on the source path only." >&2 + exit 1 +fi if [ ! -f "$CN1_DIR/dist/backend-javadoc/com/codename1/orm/session/Session.html" ]; then echo "Backend JavaDoc is missing the shared com.codename1.orm.session classes; aborting." >&2 exit 1 diff --git a/.github/scripts/generate-quality-report.py b/.github/scripts/generate-quality-report.py index b3c8350c1d4..c2842de07ec 100755 --- a/.github/scripts/generate-quality-report.py +++ b/.github/scripts/generate-quality-report.py @@ -29,6 +29,9 @@ Path("vm/backend/src"), Path("vm/backend/impl/javase"), Path("vm/backend/impl/parparvm"), + # maven/backend-test, built the same way from vm/backend/test. + Path("vm/backend/test/src"), + Path("vm/backend/test/javase"), ] DEFAULT_REPORT_TITLE = "✅ Continuous Quality Report" diff --git a/.github/workflows/car-android.yml b/.github/workflows/car-android.yml index 72e67063fd3..339ab703639 100644 --- a/.github/workflows/car-android.yml +++ b/.github/workflows/car-android.yml @@ -25,7 +25,7 @@ name: Car (Android Auto) integration # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' - 'scripts/lib/cn1ss.sh' push: branches: @@ -48,7 +48,7 @@ name: Car (Android Auto) integration # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' - 'scripts/lib/cn1ss.sh' concurrency: diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 8cefcec3378..34e25e31db1 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -150,7 +150,7 @@ jobs: run: | set -euo pipefail xvfb-run -a mvn -B -ntp -f maven/pom.xml \ - -pl core,javase,android,css-compiler,codenameone-maven-plugin,backend \ + -pl core,javase,android,css-compiler,codenameone-maven-plugin,backend,backend-test \ -am install \ -Plocal-dev-javase \ -DskipTests \ diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 27259c7e8c9..ab5ed4d1091 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -127,7 +127,7 @@ jobs: run: | cd maven JAVA_HOME="$JDK_8_HOME" bash ../scripts/ci/retry.sh \ - mvn -B -pl codenameone-maven-plugin,cn1-ads-mock -am \ + mvn -B -pl codenameone-maven-plugin,cn1-ads-mock,backend,backend-test -am \ -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install - name: Build hellocodenameone-common (JDK 17) @@ -142,12 +142,26 @@ jobs: bash scripts/ci/retry.sh \ mvn -B -f scripts/hellocodenameone/pom.xml -pl common -am install -DskipTests '-Dmaven.javadoc.skip=true' + # The CI test server (scripts/hellocodenameone/backend) as a runnable copy the + # suite jobs run with plain java (CN1SS_SERVER_DIST). Built here, where the + # plugin and the backend runtime were just installed: the musl job's container + # has neither, and building them there -- every port, through the plugin's + # dependencies -- outlasted the suite's wait for the server. + - name: Build the test server + env: + CN1SS_BACKEND_SKIP_INSTALL: '1' + run: | + set -e + JAVA17_HOME="$JDK_17_HOME" scripts/hellocodenameone/backend/server.sh dist "$GITHUB_WORKSPACE/artifacts/cn1ss-server" + test -f artifacts/cn1ss-server/classes/META-INF/cn1-backend-main + - name: Package suite classes run: | set -e tar czf suite-classes.tgz \ scripts/hellocodenameone/common/target/classes \ - maven/cn1-ads-mock/target/classes + maven/cn1-ads-mock/target/classes \ + artifacts/cn1ss-server - name: Upload suite classes uses: actions/upload-artifact@v4 with: @@ -281,6 +295,8 @@ jobs: working-directory: vm env: CN1_SHOT_OUTPUT_DIR: ${{ github.workspace }}/artifacts/linux-port/raw + # The test server prepare-suite built (see "Build the test server" there). + CN1SS_SERVER_DIST: ${{ github.workspace }}/artifacts/cn1ss-server # Full app stdout/stderr -- the only evidence when the suite wedges # mid-run (uploaded with the screenshot artifact below). CN1_APP_LOG_TEE: ${{ github.workspace }}/artifacts/linux-port/raw/app-output.log @@ -516,6 +532,7 @@ jobs: -e CN1_SHOT_OUTPUT_DIR=/cn1/artifacts/linux-port/raw-musl \ -e CN1_APP_LOG_TEE=/cn1/artifacts/linux-port/raw-musl/app-output.log \ -e CN1_REQUIRE_SUITE=true \ + -e CN1SS_SERVER_DIST=/cn1/artifacts/cn1ss-server \ -e LIBGL_ALWAYS_SOFTWARE=1 \ -e RETRY_ONLY_MATCHING \ docker.io/library/alpine:3.20 sh -ec ' diff --git a/.github/workflows/parparvm-tests-windows.yml b/.github/workflows/parparvm-tests-windows.yml index 669fd8a6cdc..6a880ffc4ab 100644 --- a/.github/workflows/parparvm-tests-windows.yml +++ b/.github/workflows/parparvm-tests-windows.yml @@ -394,7 +394,7 @@ jobs: Write-Host "mvn failed; retrying in $delay s..." Start-Sleep -Seconds $delay } - mvn -B -pl windows,codenameone-maven-plugin,cn1-ads-mock -am -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install + mvn -B -pl windows,codenameone-maven-plugin,cn1-ads-mock,backend,backend-test -am -DskipTests '-Dmaven.javadoc.skip=true' '-Plocal-dev-javase' install if ($LASTEXITCODE -eq 0) { $ok = $true; break } } if (-not $ok) { throw "mvn install failed after all retries" } @@ -446,6 +446,30 @@ jobs: if-no-files-found: error retention-days: 1 + # The CI test server (scripts/hellocodenameone/backend), which the suite + # streams its screenshots to and drives its networking tests against. Built + # once here as a runnable copy, against the runtime and plugin the install + # above put in the local repository, and run with plain java by the capture + # steps (CN1SS_SERVER_DIST) -- this x64 one and the arm64 leg, which has no + # Maven repository of its own. Git Bash runs server.sh: a bare `bash` from a + # JVM can resolve to WSL's instead. + - name: Build the test server + shell: bash + env: + CN1SS_BACKEND_SKIP_INSTALL: '1' + JAVA17_HOME: ${{ env.JDK_17_HOME }} + run: | + scripts/hellocodenameone/backend/server.sh dist "$GITHUB_WORKSPACE/artifacts/cn1ss-server" + test -f "$GITHUB_WORKSPACE/artifacts/cn1ss-server/classes/META-INF/cn1-backend-main" + + - name: Upload the test server + uses: ./.github/actions/upload-artifact-with-retry + with: + name: windows-cn1ss-server + path: artifacts/cn1ss-server + if-no-files-found: error + retention-days: 1 + # Publish the compiled (architecture-independent) core + port bytecode right # after building it, so the native arm64 screenshot leg can translate + # clang-cl-build an arm64 exe without rebuilding core -- codename1-core needs @@ -465,8 +489,8 @@ jobs: # Build the ParparVM JavaAPI, then run the FULL hellocodenameone screenshot # suite over the cn1ss WebSocket: capturesHelloSuiteOverWebSocket builds the - # suite exe (app + core + port + nativeSources via clang-cl), starts a - # Cn1ssScreenshotServer, runs the exe (it renders each test Form offscreen + # suite exe (app + core + port + nativeSources via clang-cl), starts the + # cn1ss test server (scripts/hellocodenameone/backend), runs the exe (it renders each test Form offscreen # with Direct2D/DirectWrite and streams each PNG over com.codename1.io # WebSocket), waits for CN1SS:SUITE:FINISHED, and copies every received PNG # to CN1_SHOT_OUTPUT_DIR (~112 images). This is the CI equivalent of @@ -491,6 +515,7 @@ jobs: shell: pwsh env: JDK_8_HOME: ${{ env.JDK_8_HOME }} + CN1SS_SERVER_DIST: ${{ github.workspace }}\artifacts\cn1ss-server CN1_SHOT_OUTPUT_DIR: ${{ github.workspace }}\artifacts\windows-port\raw CN1_PERFORMANCE_BINARY_OUT: ${{ github.workspace }}\artifacts\windows-port\raw\benchmark-app.exe # The suite's own translation, replayed by the performance gate below. @@ -658,6 +683,12 @@ jobs: # The Kotlin 1.6.0 compiler cannot run on this runner's JDK 21 (arm64 has no # Temurin JDK 8/11), so the hellocodenameone-common bytecode is built once on # the x64 leg and reused here (it is architecture-independent). + - name: Restore the test server + uses: ./.github/actions/download-artifact-with-retry + with: + name: windows-cn1ss-server + path: artifacts/cn1ss-server + - name: Restore hellocodenameone-common classes uses: ./.github/actions/download-artifact-with-retry with: @@ -696,6 +727,7 @@ jobs: working-directory: vm shell: pwsh env: + CN1SS_SERVER_DIST: ${{ github.workspace }}\artifacts\cn1ss-server CN1_SHOT_OUTPUT_DIR: ${{ github.workspace }}\artifacts\windows-port\raw CN1_PERFORMANCE_BINARY_OUT: ${{ github.workspace }}\artifacts\windows-port\raw\benchmark-app.exe # The suite's own translation, replayed by the performance gate below. diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml index 6a3af1f9e61..d236be2746f 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -22,6 +22,9 @@ on: - 'maven/build-engine/**' - 'maven/project-model/**' - 'maven/backend/**' + - 'maven/backend-test/**' + - 'scripts/hellocodenameone/pom.xml' + - 'scripts/hellocodenameone/backend/**' # The core classes the backend compiles as its own (the ORM and the entity # annotations, marked @SharedWithBackend and listed by # vm/backend/shared-sources.sh). They have no copy under vm/ any more, so @@ -35,6 +38,9 @@ on: - 'CodenameOne/src/com/codename1/annotations/Id.java' - 'CodenameOne/src/com/codename1/annotations/Column.java' - 'CodenameOne/src/com/codename1/annotations/DbTransient.java' + - 'CodenameOne/src/com/codename1/annotations/JsonIgnore.java' + - 'CodenameOne/src/com/codename1/annotations/JsonProperty.java' + - 'CodenameOne/src/com/codename1/io/gzip/**' - '!vm/**/README.md' - '!vm/**/readme.md' - '!vm/**/docs/**' @@ -60,6 +66,9 @@ on: - 'maven/build-engine/**' - 'maven/project-model/**' - 'maven/backend/**' + - 'maven/backend-test/**' + - 'scripts/hellocodenameone/pom.xml' + - 'scripts/hellocodenameone/backend/**' # The core classes the backend compiles as its own (the ORM and the entity # annotations, marked @SharedWithBackend and listed by # vm/backend/shared-sources.sh). They have no copy under vm/ any more, so @@ -73,6 +82,9 @@ on: - 'CodenameOne/src/com/codename1/annotations/Id.java' - 'CodenameOne/src/com/codename1/annotations/Column.java' - 'CodenameOne/src/com/codename1/annotations/DbTransient.java' + - 'CodenameOne/src/com/codename1/annotations/JsonIgnore.java' + - 'CodenameOne/src/com/codename1/annotations/JsonProperty.java' + - 'CodenameOne/src/com/codename1/io/gzip/**' - '!vm/**/README.md' - '!vm/**/readme.md' - '!vm/**/docs/**' @@ -314,7 +326,7 @@ jobs: - name: Install core + maven plugin for the backend contract working-directory: maven run: | - bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,codenameone-maven-plugin \ + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ -am install -DskipTests -Plocal-dev-javase \ -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ -Dmaven.javadoc.skip=true \ @@ -367,6 +379,10 @@ jobs: # toolchain regression fails the workflow rather than quietly # subtracting BackendDatabaseTest, BackendOrmTest and the rest from it. CN1_BACKEND_REQUIRED: 1 + # BackendWebSocketIntegrationTest translates scripts/hellocodenameone/backend + # through server.sh, which would otherwise re-install the backend runtime + # the step above just installed from this same checkout. + CN1SS_BACKEND_SKIP_INSTALL: 1 - name: Run ParparVM Benchmark working-directory: vm @@ -485,6 +501,85 @@ jobs: # every case must be strictly green, NON-STRICT included, and the set that ran # has to match the committed manifest so the suite cannot quietly shrink to the # cases it happens to pass. + backend-compiled-tests: + # The backend test framework's two runners over one test source: surefire on + # the JVM, then the same classes translated by ParparVM into a native test + # binary (cn1:backend-test), the way an application's own tests run when it + # asks for -Dcn1.backend.compiledTests=true. The project is the CI test server, + # scripts/hellocodenameone/backend, so these are also its self-checks. + # + # The compiled leg leaves out the Mockito-based test classes on its own; the + # report check at the end is what proves the binary actually ran tests, since + # a goal that skipped would otherwise leave this job green. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> $GITHUB_ENV + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> $GITHUB_ENV + + # Gates off for the reason the vm-tests install step gives. + - name: Install the backend runtime, its test support and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + + - name: Run the backend tests on the JVM and compiled + working-directory: scripts/hellocodenameone + run: | + mvn -B -Dcodename1.platform=backend -pl backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Check that both runners reported + working-directory: scripts/hellocodenameone/backend/target/surefire-reports + run: | + jvm=$(ls TEST-*.xml | grep -vc -- '-compiled.xml' || true) + compiled=$(ls TEST-*-compiled.xml 2>/dev/null | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: backend-compiled-tests + path: scripts/hellocodenameone/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + backend-websocket-conformance: name: WebSocket conformance (Autobahn) runs-on: ubuntu-24.04 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 980910452e0..45f406dde9a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -387,7 +387,7 @@ jobs: -Dcn1.binaries="${CN1_BINARIES}" \ -Pcompile-android \ -DbackendAnalysis \ - -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-parparvm -am verify + -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm -am verify - name: Type-check the SQLCipher-backed database package if: ${{ matrix.java-version == 8 }} env: @@ -602,7 +602,7 @@ jobs: - name: Generate static analysis HTML summaries if: ${{ !cancelled() && matrix.java-version == 8 }} env: - QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-parparvm/target + QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-test/target:maven/backend-parparvm/target QUALITY_REPORT_SERVER_URL: ${{ github.server_url }} QUALITY_REPORT_REPOSITORY: ${{ github.repository }} QUALITY_REPORT_REF: ${{ github.event.pull_request.head.sha || github.sha }} @@ -624,7 +624,8 @@ jobs: "build-engine:maven/build-engine/target" \ "project-model:maven/project-model/target" \ "gradle-plugin:maven/codenameone-gradle-plugin/target" \ - "backend:maven/backend/target"; do + "backend:maven/backend/target" \ + "backend-test:maven/backend-test/target"; do label="${entry%%:*}" dir="${entry#*:}" for filename in spotbugsXml.xml spotbugs.xml; do @@ -643,6 +644,12 @@ jobs: if [ -f maven/backend/target/pmd.xml ]; then cp maven/backend/target/pmd.xml quality-artifacts/static-analysis/pmd-backend.xml fi + if [ -f maven/backend-test/target/pmd.xml ]; then + cp maven/backend-test/target/pmd.xml quality-artifacts/static-analysis/pmd-backend-test.xml + fi + if [ -f maven/backend-test/target/checkstyle-result.xml ]; then + cp maven/backend-test/target/checkstyle-result.xml quality-artifacts/static-analysis/checkstyle-backend-test.xml + fi if [ -f maven/backend-parparvm/target/pmd.xml ]; then cp maven/backend-parparvm/target/pmd.xml quality-artifacts/static-analysis/pmd-backend-parparvm.xml fi @@ -744,16 +751,16 @@ jobs: - name: Generate quality report summary if: ${{ !cancelled() && matrix.java-version == 8 }} env: - QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-parparvm/target + QUALITY_REPORT_TARGET_DIRS: maven/core-unittests/target:maven/android/target:maven/ios/target:vm/ByteCodeTranslator/target:maven/codenameone-maven-plugin/target:maven/build-engine/target:maven/project-model/target:maven/codenameone-gradle-plugin/target:maven/build-hint-catalog/target:maven/build-hint-tools/target:maven/backend/target:maven/backend-test/target:maven/backend-parparvm/target # Every project listed here must produce a SpotBugs report and that # report must be empty. A missing report fails the gate too, so a # SpotBugs run that silently stops running is not mistaken for clean. - QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-plugin:build-engine:project-model:codenameone-gradle-plugin:build-hint-catalog:build-hint-tools:backend + QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-plugin:build-engine:project-model:codenameone-gradle-plugin:build-hint-catalog:build-hint-tools:backend:backend-test # PMD and Checkstyle run on the core and on the backend runtime, and each # must produce its report: without one, the forbidden-rule gate below it # has nothing to read and passes. - QUALITY_REPORT_REQUIRED_PMD: core-unittests:backend:backend-parparvm - QUALITY_REPORT_REQUIRED_CHECKSTYLE: core-unittests:backend + QUALITY_REPORT_REQUIRED_PMD: core-unittests:backend:backend-test:backend-parparvm + QUALITY_REPORT_REQUIRED_CHECKSTYLE: core-unittests:backend:backend-test QUALITY_REPORT_SERVER_URL: ${{ github.server_url }} QUALITY_REPORT_REPOSITORY: ${{ github.repository }} QUALITY_REPORT_REF: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 3fee594bff4..ff1aeb4268d 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -50,7 +50,7 @@ name: Test Android build scripts # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' - 'scripts/lib/cn1ss.sh' push: branches: @@ -96,7 +96,7 @@ name: Test Android build scripts # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' - 'scripts/lib/cn1ss.sh' # The token in secrets.CN1SS_GH_TOKEN was generated on diff --git a/.github/workflows/scripts-fidelity.yml b/.github/workflows/scripts-fidelity.yml index 829f7597caa..91c365ac9cb 100644 --- a/.github/workflows/scripts-fidelity.yml +++ b/.github/workflows/scripts-fidelity.yml @@ -57,7 +57,7 @@ name: Native theme fidelity # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' - 'scripts/lib/cn1ss.sh' push: branches: [master] @@ -99,7 +99,7 @@ name: Native theme fidelity # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' concurrency: # Only the latest revision needs validation. PR numbers keep forks isolated; diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index c1ab94c9f5f..692507b15a9 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -42,7 +42,7 @@ on: - vm/** - tests/** - maven/** - - vm/backend/demo/cn1ss/** + - scripts/hellocodenameone/backend/** - scripts/lib/cn1ss.sh - .github/workflows/scripts-ios-native.yml - scripts/ios/create-shared-scheme.py @@ -98,7 +98,7 @@ on: - vm/** - tests/** - maven/** - - vm/backend/demo/cn1ss/** + - scripts/hellocodenameone/backend/** - scripts/lib/cn1ss.sh - .github/workflows/scripts-ios-native.yml - scripts/ios/create-shared-scheme.py @@ -940,7 +940,7 @@ jobs: # auto-enables from codename1.watchMain in the sample, so build-ios-app.sh # generates the
Watch target alongside the iOS app. This job renders # the cn1ss suite on the watch simulator through the Core Graphics backend - # (no GL/Metal on watchOS), streams frames to the same Cn1ssScreenshotServer + # (no GL/Metal on watchOS), streams frames to the same cn1ss test server # WS sink the iOS jobs use, and compares against scripts/ios/screenshots-watch. # Isolated in its own job (like build-ios-metal) so the watch result is a # distinct check and a regression there doesn't mask the iOS path. @@ -1098,7 +1098,7 @@ jobs: #
TV target alongside the iOS app. tvOS reuses the iOS UIApplicationMain # entry and the Metal renderer (no OpenGL ES on tvOS); this job builds the TV # target for the appletvsimulator, renders the cn1ss suite and streams frames - # to the same Cn1ssScreenshotServer the iOS/watch jobs use, comparing against + # to the same cn1ss test server the iOS/watch jobs use, comparing against # scripts/ios/screenshots-tv. # # BLOCKING (a hard golden gate, like build-ios-watch): the tvOS slice diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index eb3b2f4ca00..0e8372dbe4e 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -53,7 +53,7 @@ on: # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' push: branches: [ master ] paths: @@ -93,7 +93,7 @@ on: # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' concurrency: # Only the latest revision needs validation. PR numbers keep forks isolated; @@ -335,14 +335,14 @@ jobs: # THIS LEG RUNS THE TRANSLATED SCREENSHOT SERVER, and it is the only one # that does. Every other leg runs the same Java on a JVM, which covers the # protocol but not the arm that ships. This job is the right host for it: it - # is Linux, which is what build.sh targets; it already sets up JDK 8 and + # is Linux, where the native backend links; it already sets up the JDKs and # builds the ParparVM compiler above; it is a single job rather than a # matrix, so the binary is built once; and its client is the browser's own # WebSocket rather than one of ours, which makes it the only pairing of our # shipping server with a third-party RFC 6455 implementation. # # NOT with CN1_BACKEND_HTTPS=0, tempting as that is for avoiding these - # packages: build.sh deletes cn1_backend_crypto.c in that mode, and a native + # packages: the build deletes cn1_backend_crypto.c in that mode, and a native # with no C symbol takes its Java method with it -- so Crypto.sha1 would # vanish and every handshake would compute the wrong accept value. - name: Install the native backend toolchain @@ -351,21 +351,13 @@ jobs: sudo apt-get install -y --no-install-recommends \ clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev - - name: Cache the native screenshot server - uses: actions/cache@v5 - with: - path: ${{ runner.temp }}/cn1ss-native-cache - # The translator and the boot classpath are inputs to build.sh too; a key - # without them restores a binary built by a different translator. - key: ${{ runner.os }}-cn1ss-native-${{ hashFiles('vm/backend/src/com/codename1/backend/**', 'vm/backend/impl/parparvm/**', 'vm/backend/native/**', 'vm/backend/demo/cn1ss/**', 'vm/backend/build.sh', 'vm/ByteCodeTranslator/src/**', 'vm/JavaAPI/src/**') }} - - name: Run JavaScript screenshot browser tests env: - # The translated binary, built and self-checked by scripts/lib/cn1ss.sh - # before the suite starts, so a broken one costs seconds rather than the - # whole 40-minute timeout. + # The translated binary, built by scripts/hellocodenameone/backend/server.sh + # through the Maven plugin and self-checked by scripts/lib/cn1ss.sh before + # the suite starts, so a broken one costs minutes rather than the whole + # 40-minute timeout. CN1SS_WS_SERVER: native - CN1SS_NATIVE_CACHE_DIR: ${{ runner.temp }}/cn1ss-native-cache run: | mkdir -p "${ARTIFACTS_DIR}" ./scripts/run-javascript-browser-tests.sh "${{ steps.locate_bundle.outputs.bundle }}" "${GITHUB_WORKSPACE}/scripts/javascript/screenshots" diff --git a/.github/workflows/scripts-macos.yml b/.github/workflows/scripts-macos.yml index b0d3cb8f7f1..79b25f02119 100644 --- a/.github/workflows/scripts-macos.yml +++ b/.github/workflows/scripts-macos.yml @@ -72,7 +72,7 @@ on: # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' push: branches: [ master ] paths: @@ -113,7 +113,7 @@ on: # emulator legs as well would buy nothing and cost a great deal: a change to # the ORM, the database layer or the HTTP parser cannot reach this # transport. - - 'vm/backend/demo/cn1ss/**' + - 'scripts/hellocodenameone/backend/**' concurrency: # Only the latest revision needs validation. PR numbers keep forks isolated; # event names keep a manual/scheduled run from cancelling a push or PR run. diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml index 368b4b483ec..46976964dcf 100644 --- a/.github/workflows/windows-cross-build-run.yml +++ b/.github/workflows/windows-cross-build-run.yml @@ -239,6 +239,19 @@ jobs: test -f "$CN1_CROSS_EXE_OUT" ls -l "$CN1_CROSS_EXE_OUT" + # The CI test server (scripts/hellocodenameone/backend) the suite streams its + # screenshots to and drives its networking tests against, as a runnable copy + # the Windows job runs with plain java: that job has a JDK and no Maven + # repository with the backend in it. Built here, where the plugin and the + # backend runtime are a Maven install away. + - name: Build the test server + env: + JAVA17_HOME: ${{ env.JDK_17_HOME }} + RETRY_ONLY_MATCHING: transient + run: | + bash scripts/ci/retry.sh scripts/hellocodenameone/backend/server.sh dist "$GITHUB_WORKSPACE/artifacts/cn1ss-server" + test -f "$GITHUB_WORKSPACE/artifacts/cn1ss-server/classes/META-INF/cn1-backend-main" + - name: Upload cross-built suite exe uses: actions/upload-artifact@v4 with: @@ -247,6 +260,7 @@ jobs: path: | artifacts/WinHelloMain.exe artifacts/WinHelloMain.pdb + artifacts/cn1ss-server/** if-no-files-found: error retention-days: 1 @@ -260,8 +274,9 @@ jobs: uses: actions/checkout@v4 # Only a JDK is needed here: with CN1_PREBUILT_EXE set the capture test - # neither translates nor builds -- it just compiles the Java cn1ss server, - # launches the prebuilt exe and collects the PNGs it streams. No MSVC / + # neither translates nor builds -- it runs the test server the cross-build + # job built (CN1SS_SERVER_DIST), launches the prebuilt exe and collects the + # PNGs it streams. No MSVC / # Ninja / clang. windows-latest ships the WebView2 Evergreen runtime the # BrowserComponent peer loads at activation. - name: Set up JDK 17 @@ -292,6 +307,7 @@ jobs: shell: pwsh env: CN1_PREBUILT_EXE: ${{ github.workspace }}\artifacts\WinHelloMain.exe + CN1SS_SERVER_DIST: ${{ github.workspace }}\artifacts\cn1ss-server CN1_PERFORMANCE_BINARY_OUT: ${{ github.workspace }}\artifacts\windows-port\raw\benchmark-app.exe CN1_REQUIRE_PERFORMANCE: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} CN1_SHOT_OUTPUT_DIR: ${{ github.workspace }}\artifacts\windows-port\raw diff --git a/CLAUDE.md b/CLAUDE.md index 0cc764ed55d..0c110e07716 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,8 +327,7 @@ a line of their own above the type declaration, and every backend build compiles from there: - `vm/backend/shared-sources.sh` lists them for the shell builds (`build.sh`, - `run-javase.sh`, `ws-conformance.sh`, `scripts/lib/cn1ss.sh`, - `scripts/check-native-signatures.sh`). + `run-javase.sh`, `ws-conformance.sh`, `scripts/check-native-signatures.sh`). - `maven/backend/pom.xml` copies them into `target/generated-sources/backend-shared` with the same line match, for the compiled jar and the `parparvm-sources` jar. diff --git a/CodenameOne/src/com/codename1/io/gzip/Adler32.java b/CodenameOne/src/com/codename1/io/gzip/Adler32.java index 0b18ece22ea..18706a3d479 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Adler32.java +++ b/CodenameOne/src/com/codename1/io/gzip/Adler32.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final public class Adler32 implements Checksum { // largest prime smaller than 65536 diff --git a/CodenameOne/src/com/codename1/io/gzip/CRC32.java b/CodenameOne/src/com/codename1/io/gzip/CRC32.java index 5c4679f337f..dd694da1b44 100644 --- a/CodenameOne/src/com/codename1/io/gzip/CRC32.java +++ b/CodenameOne/src/com/codename1/io/gzip/CRC32.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final public class CRC32 implements Checksum { // The following logic has come from zlib.1.2. diff --git a/CodenameOne/src/com/codename1/io/gzip/Checksum.java b/CodenameOne/src/com/codename1/io/gzip/Checksum.java index 179ad319994..a07dce821d3 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Checksum.java +++ b/CodenameOne/src/com/codename1/io/gzip/Checksum.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend interface Checksum { void update(byte[] buf, int index, int len); diff --git a/CodenameOne/src/com/codename1/io/gzip/Deflate.java b/CodenameOne/src/com/codename1/io/gzip/Deflate.java index 48a7a2a17cd..0b9a0fbb142 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Deflate.java +++ b/CodenameOne/src/com/codename1/io/gzip/Deflate.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend public final class Deflate { diff --git a/CodenameOne/src/com/codename1/io/gzip/Deflater.java b/CodenameOne/src/com/codename1/io/gzip/Deflater.java index a5d629466be..d27e614e1d3 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Deflater.java +++ b/CodenameOne/src/com/codename1/io/gzip/Deflater.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final public class Deflater extends ZStream { static final private int MAX_WBITS = 15; // 32K LZ77 window diff --git a/CodenameOne/src/com/codename1/io/gzip/DeflaterOutputStream.java b/CodenameOne/src/com/codename1/io/gzip/DeflaterOutputStream.java index 0984fdbc7bb..20b8553cfcf 100644 --- a/CodenameOne/src/com/codename1/io/gzip/DeflaterOutputStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/DeflaterOutputStream.java @@ -33,6 +33,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING import java.io.IOException; import java.io.OutputStream; +@com.codename1.impl.SharedWithBackend public class DeflaterOutputStream extends FilterOutputStream { protected static final int DEFAULT_BUFSIZE = 512; diff --git a/CodenameOne/src/com/codename1/io/gzip/FilterInputStream.java b/CodenameOne/src/com/codename1/io/gzip/FilterInputStream.java index 16d11aea8a7..9ed19ccbccc 100644 --- a/CodenameOne/src/com/codename1/io/gzip/FilterInputStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/FilterInputStream.java @@ -26,6 +26,7 @@ import java.io.InputStream; /// Simple version of filter input stream +@com.codename1.impl.SharedWithBackend public class FilterInputStream extends InputStream { protected InputStream in; diff --git a/CodenameOne/src/com/codename1/io/gzip/FilterOutputStream.java b/CodenameOne/src/com/codename1/io/gzip/FilterOutputStream.java index ed46bc55227..ca914d4cbac 100644 --- a/CodenameOne/src/com/codename1/io/gzip/FilterOutputStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/FilterOutputStream.java @@ -26,6 +26,7 @@ import java.io.OutputStream; /// Dummy implementation of filter output stream +@com.codename1.impl.SharedWithBackend public class FilterOutputStream extends OutputStream { protected OutputStream out; diff --git a/CodenameOne/src/com/codename1/io/gzip/GZIPException.java b/CodenameOne/src/com/codename1/io/gzip/GZIPException.java index a9ed456d5fd..58c15b29c59 100644 --- a/CodenameOne/src/com/codename1/io/gzip/GZIPException.java +++ b/CodenameOne/src/com/codename1/io/gzip/GZIPException.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend public class GZIPException extends java.io.IOException { public GZIPException() { super(); diff --git a/CodenameOne/src/com/codename1/io/gzip/GZIPHeader.java b/CodenameOne/src/com/codename1/io/gzip/GZIPHeader.java index 0db8330f7bc..4f912be25df 100644 --- a/CodenameOne/src/com/codename1/io/gzip/GZIPHeader.java +++ b/CodenameOne/src/com/codename1/io/gzip/GZIPHeader.java @@ -39,6 +39,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING /// #### See also /// /// - "http://www.ietf.org/rfc/rfc1952.txt" +@com.codename1.impl.SharedWithBackend public class GZIPHeader { public static final byte OS_MSDOS = (byte) 0x00; diff --git a/CodenameOne/src/com/codename1/io/gzip/GZIPInputStream.java b/CodenameOne/src/com/codename1/io/gzip/GZIPInputStream.java index f04c9fd57b8..6ee7e2701ef 100644 --- a/CodenameOne/src/com/codename1/io/gzip/GZIPInputStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/GZIPInputStream.java @@ -29,11 +29,10 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; -import com.codename1.io.Log; - import java.io.IOException; import java.io.InputStream; +@com.codename1.impl.SharedWithBackend public class GZIPInputStream extends InflaterInputStream { public GZIPInputStream(InputStream in) throws IOException { @@ -131,16 +130,14 @@ public void readHeader() throws IOException { while (inflater.istate.inParsingHeader()); } - private int fill(byte[] buf) { + private int fill(byte[] buf) throws IOException { int len = buf.length; int n = 0; do { - int i = -1; - try { - i = in.read(buf, n, buf.length - n); - } catch (IOException e) { - Log.e(e); - } + // A failed read is the caller's to see: logging it and reporting + // end of stream turned a broken connection into "no input", or into + // a header that silently parsed short. + int i = in.read(buf, n, buf.length - n); if (i == -1) { break; } diff --git a/CodenameOne/src/com/codename1/io/gzip/GZIPOutputStream.java b/CodenameOne/src/com/codename1/io/gzip/GZIPOutputStream.java index 2b02c4e678f..3259fb100f7 100644 --- a/CodenameOne/src/com/codename1/io/gzip/GZIPOutputStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/GZIPOutputStream.java @@ -32,6 +32,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING import java.io.IOException; import java.io.OutputStream; +@com.codename1.impl.SharedWithBackend public class GZIPOutputStream extends DeflaterOutputStream { public GZIPOutputStream(OutputStream out) throws IOException { diff --git a/CodenameOne/src/com/codename1/io/gzip/InfBlocks.java b/CodenameOne/src/com/codename1/io/gzip/InfBlocks.java index b7f4d7787d2..0602407a0c6 100644 --- a/CodenameOne/src/com/codename1/io/gzip/InfBlocks.java +++ b/CodenameOne/src/com/codename1/io/gzip/InfBlocks.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final class InfBlocks { // Table for deflate from PKZIP's appnote.txt. static final int[] border = { // Order of the bit length code lengths diff --git a/CodenameOne/src/com/codename1/io/gzip/InfCodes.java b/CodenameOne/src/com/codename1/io/gzip/InfCodes.java index b3db365d8d5..e9fe8d2d204 100644 --- a/CodenameOne/src/com/codename1/io/gzip/InfCodes.java +++ b/CodenameOne/src/com/codename1/io/gzip/InfCodes.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final class InfCodes { static final private int[] inflate_mask = { diff --git a/CodenameOne/src/com/codename1/io/gzip/InfTree.java b/CodenameOne/src/com/codename1/io/gzip/InfTree.java index 4b13631ec84..ccbbb82c9d2 100644 --- a/CodenameOne/src/com/codename1/io/gzip/InfTree.java +++ b/CodenameOne/src/com/codename1/io/gzip/InfTree.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final class InfTree { static final int fixed_bl = 9; diff --git a/CodenameOne/src/com/codename1/io/gzip/Inflate.java b/CodenameOne/src/com/codename1/io/gzip/Inflate.java index f2611fc6a02..240618df3f1 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Inflate.java +++ b/CodenameOne/src/com/codename1/io/gzip/Inflate.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final class Inflate { static final int Z_NO_FLUSH = 0; diff --git a/CodenameOne/src/com/codename1/io/gzip/Inflater.java b/CodenameOne/src/com/codename1/io/gzip/Inflater.java index c52c36433f2..f745a1efa84 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Inflater.java +++ b/CodenameOne/src/com/codename1/io/gzip/Inflater.java @@ -35,6 +35,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final public class Inflater extends ZStream { static final private int MAX_WBITS = 15; // 32K LZ77 window diff --git a/CodenameOne/src/com/codename1/io/gzip/InflaterInputStream.java b/CodenameOne/src/com/codename1/io/gzip/InflaterInputStream.java index 21d83f90d04..b385a20f068 100644 --- a/CodenameOne/src/com/codename1/io/gzip/InflaterInputStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/InflaterInputStream.java @@ -33,6 +33,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING import java.io.IOException; import java.io.InputStream; +@com.codename1.impl.SharedWithBackend public class InflaterInputStream extends FilterInputStream { protected static final int DEFAULT_BUFSIZE = 512; protected final Inflater inflater; diff --git a/CodenameOne/src/com/codename1/io/gzip/JZlib.java b/CodenameOne/src/com/codename1/io/gzip/JZlib.java index 6fabc427865..e8164ac4e66 100644 --- a/CodenameOne/src/com/codename1/io/gzip/JZlib.java +++ b/CodenameOne/src/com/codename1/io/gzip/JZlib.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final public class JZlib { static final public int MAX_WBITS = 15; // 32K LZ77 window static final public int DEF_WBITS = MAX_WBITS; diff --git a/CodenameOne/src/com/codename1/io/gzip/StaticTree.java b/CodenameOne/src/com/codename1/io/gzip/StaticTree.java index f65d6d8465e..3b930232ec0 100644 --- a/CodenameOne/src/com/codename1/io/gzip/StaticTree.java +++ b/CodenameOne/src/com/codename1/io/gzip/StaticTree.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final class StaticTree { // Bit length codes must not exceed MAX_BL_BITS bits static final int MAX_BL_BITS = 7; diff --git a/CodenameOne/src/com/codename1/io/gzip/Tree.java b/CodenameOne/src/com/codename1/io/gzip/Tree.java index 1103fb762da..27192979e3b 100644 --- a/CodenameOne/src/com/codename1/io/gzip/Tree.java +++ b/CodenameOne/src/com/codename1/io/gzip/Tree.java @@ -34,6 +34,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING package com.codename1.io.gzip; +@com.codename1.impl.SharedWithBackend final class Tree { // Bit length codes must not exceed MAX_BL_BITS bits static final int MAX_BL_BITS = 7; diff --git a/CodenameOne/src/com/codename1/io/gzip/ZStream.java b/CodenameOne/src/com/codename1/io/gzip/ZStream.java index 91d04cc0fb2..534032c21d5 100644 --- a/CodenameOne/src/com/codename1/io/gzip/ZStream.java +++ b/CodenameOne/src/com/codename1/io/gzip/ZStream.java @@ -40,6 +40,7 @@ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING /// #### Deprecated /// /// Not for public use in the future. +@com.codename1.impl.SharedWithBackend @Deprecated public class ZStream { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/ajax/XMLHttpRequest.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/ajax/XMLHttpRequest.java index 995c2475d14..bb95bef45ec 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/ajax/XMLHttpRequest.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/ajax/XMLHttpRequest.java @@ -59,6 +59,13 @@ static XMLHttpRequest create() { void setOnLoad(Object handler); void setOnError(Object handler); void setOnProgress(Object handler); + /** + * Do not call this on the ParparVM JavaScript port: the JSO bridge maps + * a no-argument {@code getXxx()} to a read of the property {@code xxx}, + * so this reads {@code allResponseHeaders}, which does not exist, and + * always answers null. Use NetworkConnection.responseHeaders(xhr), a + * {@code @JSBody} that invokes the method. + */ String getAllResponseHeaders(); String getResponseHeader(String name); } \ No newline at end of file diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 100a127efc5..414e856d9e1 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -10571,6 +10571,20 @@ public void setHeader(Object connection, String key, String val) { JavaScriptNetworkAdapter.setHeader((JavaScriptNetworkAdapter.Connection) connection, key, val); } + /// True: [#setReadTimeout(Object, int)] is honoured, so a caller that checks + /// this before setting one gets the timeout it asked for. + @Override + public boolean isReadTimeoutSupported() { + return true; + } + + @Override + public void setReadTimeout(Object connection, int readTimeout) { + if (connection instanceof NetworkConnection) { + ((NetworkConnection) connection).setReadTimeout(readTimeout); + } + } + @Override public void setHttpMethod(Object connection, String method) throws IOException { JavaScriptNetworkAdapter.setHttpMethod((JavaScriptNetworkAdapter.Connection) connection, method); @@ -12493,7 +12507,7 @@ public InputStream getArrayBufferInputStream(String url){ Uint8Array responseBytes = toResponseBytes(req); if (responseBytes == null) { - System.out.println(req.getAllResponseHeaders()); + System.out.println(NetworkConnection.responseHeaders(req)); System.out.println(req.getStatusText()); System.out.println("Failed to load resource "+url); System.out.println("Status code was "+req.getStatus()); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java index 50bfe5203c7..7b1b8f7ba88 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/NetworkConnection.java @@ -49,6 +49,7 @@ public class NetworkConnection implements JavaScriptNetworkAdapter.Connection { private boolean read; private boolean write; private int timeout=5000; + private int readTimeout = -1; private XMLHttpRequest req; private ByteArrayOutputStream body; private boolean post; @@ -156,18 +157,22 @@ public InputStream openInputStream() throws IOException { req.setRequestHeader(h.name, h.value); } //req.setResponseType("arraybuffer"); - if ( body != null ){ - - //req.send(new String(body.toByteArray(), "UTF-8")); - ((XHRBlob)req).send(BlobUtil.createBlob(body.toByteArray(), "application/octet-stream")); - } else { - req.send(); + // Through send(), which applies the request's timeout and turns the + // TimeoutError a synchronous XHR throws into this method's IOException -- + // the error path a timed-out request takes on every other port. The + // timeouts were ignored here, so a request past its timeout simply waited + // for the answer; and NetworkManager's fallback timeout thread cannot run + // while a synchronous request holds the worker. + String failure = send(req, body == null ? null + : BlobUtil.createBlob(body.toByteArray(), "application/octet-stream"), requestTimeout()); + if (failure != null) { + throw new IOException("Failed to load " + url + ": " + failure); } Uint8Array responseBytes = toResponseBytes(req); if (responseBytes == null || req.getStatus() == 0 ){ - System.out.println(req.getAllResponseHeaders()); + System.out.println(allResponseHeaders()); System.out.println(req.getStatusText()); System.out.println("Failed to load url "+url); System.out.println("Status code was "+req.getStatus()); @@ -185,6 +190,10 @@ private Uint8Array toResponseBytes(XMLHttpRequest req) { if ("arraybuffer".equals(req.getResponseType()) && req.getResponse() != null) { return Uint8Array.create((ArrayBuffer)req.getResponse()); } + if ("arraybuffer".equals(req.getResponseType())) { + // responseText throws InvalidStateError for an arraybuffer request. + return null; + } String responseText = req.getResponseText(); if (responseText == null) { return null; @@ -260,10 +269,58 @@ public String getHeaderField(String name){ * connection callback, which aborted the request the app was waiting on. */ private String allResponseHeaders() { - String headers = req == null ? null : req.getAllResponseHeaders(); + String headers = req == null ? null : responseHeaders(req); return headers == null ? "" : headers; } + /** + * The XMLHttpRequest's getAllResponseHeaders(), called as the method it is. + * + * Not through {@link XMLHttpRequest#getAllResponseHeaders()}: the ParparVM + * JSO bridge maps every no-argument {@code getXxx()} on a JSObject + * interface to a read of the property {@code xxx}, so that call read + * {@code xhr.allResponseHeaders} -- a property no browser has -- and + * answered null for every response. Every getHeaderField() on the + * JavaScript port therefore answered null, whatever the server sent and + * whatever Access-Control-Expose-Headers allowed. A @JSBody runs its + * script verbatim, so the method is really invoked. + */ + /// The XHR timeout for this request, in milliseconds, or 0 for none. A + /// synchronous XHR has one deadline for the whole request, so it gets the + /// connect and the read timeout together; -1 for either means unset, which is + /// what a request that set neither passes. + int requestTimeout() { + if (timeout > 0 && readTimeout > 0) { + return timeout + readTimeout; + } + return Math.max(Math.max(timeout, readTimeout), 0); + } + + /// ConnectionRequest.setReadTimeout, through HTML5Implementation. + public void setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + } + + /// Sends `xhr` with `body` (null for none) under a `timeoutMillis` deadline + /// (0 for none), answering why it failed or null. Setting the timeout is + /// inside the try, because a synchronous XHR on a window rather than a + /// worker refuses one outright. A request that ended with no response at all + /// -- status 0 and nothing received, which is how a timed-out synchronous XHR + /// can come back without throwing -- is a failure too: reading on from there + /// reached `responseText`, which an arraybuffer request throws for, and that + /// JavaScript error ended the network thread with no callback of any kind. + @JSBody(params={"xhr", "body", "timeoutMillis"}, script="try {" + + " if (timeoutMillis > 0) { try { xhr.timeout = timeoutMillis; } catch (ignored) {} }" + + " if (body) { xhr.send(body); } else { xhr.send(); }" + + " if (xhr.status === 0 && !xhr.response) { return 'NetworkError: no response'; }" + + " return null;" + + " } catch (e) { return (e && e.name ? e.name : 'Error') + (e && e.message ? ': ' + e.message : ''); }") + static native String send(XMLHttpRequest xhr, Blob body, int timeoutMillis); + + @JSBody(params={"xhr"}, script="return (xhr && typeof xhr.getAllResponseHeaders === 'function') " + + "? xhr.getAllResponseHeaders() : null;") + static native String responseHeaders(XMLHttpRequest xhr); + public String[] getHeaderFieldNames() { List out = new ArrayList(); List headerStrings = StringUtil.tokenize(allResponseHeaders(), "\n"); diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index c25ad12abea..403383947b2 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -998,11 +998,16 @@ if (typeof global.cn1_com_codename1_ui_PeerComponent_styleChanged_java_lang_Stri // through a ``function*`` boundary -- about 750k generator // allocations for a single theme.res load. // -// The Java byte[] is a plain JS Array with index access; storing -// the raw 0..255 Uint8Array value is fine because the existing -// scalar ``read()`` path already returns the unsigned byte value -// and the rest of the runtime treats negative bytes via ``& 0xff`` -// masking on the read side. +// The Java byte[] is a plain JS Array with index access, and it must +// hold SIGNED values (-128..127): BALOAD/BASTORE are emitted as plain +// array reads and writes, so whatever number sits in the slot is what +// Java code sees. Storing the raw 0..255 Uint8Array value made every +// byte above 0x7f a value no Java byte can have -- ``b[i] == (byte) i`` +// was false for it, ``b[i] < 0`` never held, and a downloaded binary +// file read back with the right length and the wrong contents. Each +// value is therefore sign-extended on the way in, the same +// ``v > 127 ? v - 256 : v`` conversion the crypto, vault, sqlite and +// bluetooth bindings in this file already apply. bindNative([ // Void return → no ``_R_void`` suffix; the int signature variant // is registered too in case future translator versions normalise it. @@ -1025,7 +1030,7 @@ bindNative([ // direct memory access. ``dst`` is a plain JS Array carrying Java // byte[] metadata, so indexed store is the same as for any array. for (let i = 0; i < n; i++) { - dst[dO + i] = raw[so + i]; + dst[dO + i] = (raw[so + i] << 24) >> 24; } return null; }); @@ -1073,10 +1078,11 @@ bindNative([ if (!bytes) { return jvm.wrapJsObject(new Uint8Array(0), "com_codename1_html5_js_typedarrays_Uint8Array"); } - // ``bytes`` is the Java byte[] -- a plain JS Array of 0..255 - // numbers with byte[] metadata stamped on. ``Uint8Array.from`` - // accepts any iterable / array-like, so this is a single - // native-loop copy. + // ``bytes`` is the Java byte[] -- a plain JS Array of signed + // -128..127 numbers with byte[] metadata stamped on. + // ``Uint8Array.from`` accepts any iterable / array-like and stores + // each value modulo 256, so -1 lands as 255: a single native-loop + // copy that is also the signed-to-unsigned conversion. const u8 = Uint8Array.from(bytes); return jvm.wrapJsObject(u8, "com_codename1_html5_js_typedarrays_Uint8Array"); }); @@ -5738,7 +5744,7 @@ function resolveBaseTestFromRunnable(runnable) { // The JS port runs in a Web Worker, which has no DOM but does have WebSocket. // Every screenshot funnels through emitCn1ssChunks(); rather than chunk the // PNG as base64 over the console (rate-limited, log-scraped on the host), we -// ship it straight to Cn1ssScreenshotServer over a browser WebSocket -- the +// ship it straight to the cn1ss test server over a browser WebSocket -- the // same single pipeline the native ports use via the core WebSocket. The // browser handles RFC6455 framing, so we just send a META text frame followed // by the binary PNG, matching what the server parses. The server replies with @@ -5824,7 +5830,7 @@ function cn1ssWsSendNow(test, bytes) { return false; } try { - // META is a JSON object (Cn1ssScreenshotServer.parseMeta expects JSON, + // META is a JSON object (the server's Cn1ssEndpoint parses it as JSON, // matching the native ports' Cn1ssWebSocketSink). The hash is omitted -- // the server computes its own for dedup and only flags a mismatch when an // expected hash is supplied; png_bytes lets it catch a truncated transfer. @@ -5867,7 +5873,7 @@ function cn1ssWsSend(base64, test) { } // Single screenshot transport for the JS port: ship the captured PNG to the -// host-side Cn1ssScreenshotServer over the worker WebSocket. The function is +// host-side cn1ss test server over the worker WebSocket. The function is // still named emitCn1ssChunks (and still takes a base64 PNG) because the DOM / // host-canvas capture paths -- emitCurrentFormScreenshotDom, emitChannelFastJs // -- feed it a base64 data-URL payload; we decode and send it as one binary diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_net.c b/Ports/LinuxPort/nativeSources/cn1_linux_net.c index 48c4d096669..74de3439281 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_net.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_net.c @@ -74,6 +74,18 @@ typedef struct { char* statusMessage; int performed; int contentLength; + + /* ConnectionRequest.setHttpMethod, as asked for -- GET and POST included, + * which override the post flag; NULL when the request named none. */ + char* method; + /* ConnectionRequest.setTimeout and setReadTimeout, in milliseconds; 0 is + * none. Both were accepted and ignored, so a request to a server that never + * answered waited forever. */ + long connectTimeoutMs; + long readTimeoutMs; + /* Set when the transfer itself failed -- refused, timed out, reset -- as + * opposed to answering with an error status. */ + int failed; } CN1Http; static void cn1HttpEnsureResp(CN1Http* c, int extra) { @@ -153,11 +165,45 @@ static void cn1HttpPerform(CN1Http* c) { if (c->reqHeaders) { curl_easy_setopt(c->easy, CURLOPT_HTTPHEADER, c->reqHeaders); } - if (c->post) { - curl_easy_setopt(c->easy, CURLOPT_POST, 1L); - curl_easy_setopt(c->easy, CURLOPT_READFUNCTION, cn1HttpReadCb); - curl_easy_setopt(c->easy, CURLOPT_READDATA, c); - curl_easy_setopt(c->easy, CURLOPT_POSTFIELDSIZE, (long) c->reqLen); + { + /* A buffered body is sent whatever the post flag says: RequestBuilder + * writes one for a DELETE without setting it, and CUSTOMREQUEST below then + * sent the right verb with an empty body (the Windows port already sends + * whatever was buffered). An explicit GET, HEAD or POST wins over both: + * ConnectionRequest lets a request set setPost(true) for how its arguments + * are sent and then ask for GET, or the reverse, and the method it asked + * for is the one it means. */ + int sendBody = c->post || c->reqLen > 0; + if (c->method != 0 && (strcmp(c->method, "GET") == 0 || strcmp(c->method, "HEAD") == 0)) { + sendBody = 0; + } else if (c->method != 0 && strcmp(c->method, "POST") == 0) { + sendBody = 1; + } + if (sendBody) { + curl_easy_setopt(c->easy, CURLOPT_POST, 1L); + curl_easy_setopt(c->easy, CURLOPT_READFUNCTION, cn1HttpReadCb); + curl_easy_setopt(c->easy, CURLOPT_READDATA, c); + curl_easy_setopt(c->easy, CURLOPT_POSTFIELDSIZE, (long) c->reqLen); + } + } + if (c->method != 0 && strcmp(c->method, "GET") != 0 && strcmp(c->method, "POST") != 0) { + /* After the body setup: CUSTOMREQUEST replaces only the request line's + * verb, so a PUT or PATCH still sends the body POST would have. */ + if (strcmp(c->method, "HEAD") == 0) { + curl_easy_setopt(c->easy, CURLOPT_NOBODY, 1L); + } else { + curl_easy_setopt(c->easy, CURLOPT_CUSTOMREQUEST, c->method); + } + } + if (c->connectTimeoutMs > 0) { + curl_easy_setopt(c->easy, CURLOPT_CONNECTTIMEOUT_MS, c->connectTimeoutMs); + } + if (c->readTimeoutMs > 0) { + /* A read timeout is an idle limit, not a deadline for the whole transfer + * -- a large download must not be cut off for taking long. libcurl's + * idle limit is in whole seconds, so round up. */ + curl_easy_setopt(c->easy, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(c->easy, CURLOPT_LOW_SPEED_TIME, (c->readTimeoutMs + 999) / 1000); } /* curl_easy_perform runs the whole blocking HTTP transfer; yield to the GC * across it so a thread parked in the network stack never stalls a GC mark. */ @@ -166,6 +212,7 @@ static void cn1HttpPerform(CN1Http* c) { CN1_RESUME_THREAD; curl_easy_getinfo(c->easy, CURLINFO_RESPONSE_CODE, &code); c->status = code; + c->failed = rc != CURLE_OK; c->statusMessage = strdup(rc == CURLE_OK ? "OK" : curl_easy_strerror(rc)); } @@ -195,6 +242,41 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetMethod___long_boolean(CODE } } +JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetCustomMethod___long_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_OBJECT method) { + CN1Http* c = (CN1Http*) (intptr_t) connection; + const char* m; + if (!c || method == JAVA_NULL) { + return; + } + m = stringToUTF8(threadStateData, method); + free(c->method); + /* Kept even for GET and POST: an explicit one overrides the post flag. */ + c->method = m == 0 ? 0 : strdup(m); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetConnectTimeout___long_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_INT millis) { + CN1Http* c = (CN1Http*) (intptr_t) connection; + if (c) { + c->connectTimeoutMs = millis > 0 ? (long) millis : 0; + } +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetReadTimeout___long_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_INT millis) { + CN1Http* c = (CN1Http*) (intptr_t) connection; + if (c) { + c->readTimeoutMs = millis > 0 ? (long) millis : 0; + } +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_httpFailure___long_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection) { + CN1Http* c = (CN1Http*) (intptr_t) connection; + if (!c) { + return JAVA_NULL; + } + cn1HttpPerform(c); + return c->failed ? newStringFromCString(threadStateData, c->statusMessage ? c->statusMessage : "failed") : JAVA_NULL; +} + JAVA_VOID com_codename1_impl_linux_LinuxNative_httpSetHeader___long_java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG connection, JAVA_OBJECT key, JAVA_OBJECT value) { CN1Http* c = (CN1Http*) (intptr_t) connection; char* k; @@ -347,5 +429,6 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_httpClose___long(CODENAME_ONE_THR free(c->respBody); free(c->url); free(c->statusMessage); + free(c->method); free(c); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 6847c31ffe0..a73399a623e 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2582,6 +2582,30 @@ public Object connect(String url, boolean read, boolean write) throws IOExceptio return new LinuxHttpConnection(c); } + @Override + public Object connect(String url, boolean read, boolean write, int timeout) throws IOException { + Object c = connect(url, read, write); + LinuxNative.httpSetConnectTimeout(((LinuxHttpConnection) c).peer, timeout); + return c; + } + + @Override + public void setHttpMethod(Object connection, String method) throws IOException { + LinuxNative.httpSetCustomMethod(((LinuxHttpConnection) connection).peer, method); + } + + /// True: [#setReadTimeout(Object, int)] is honoured, so a caller that checks + /// this before setting one gets the timeout it asked for. + @Override + public boolean isReadTimeoutSupported() { + return true; + } + + @Override + public void setReadTimeout(Object connection, int readTimeout) { + LinuxNative.httpSetReadTimeout(((LinuxHttpConnection) connection).peer, readTimeout); + } + @Override public void setHeader(Object connection, String key, String val) { LinuxNative.httpSetHeader(((LinuxHttpConnection) connection).peer, key, val); @@ -2681,7 +2705,15 @@ public InputStream getResourceAsStream(Class cls, String resource) { @Override public int getResponseCode(Object connection) throws IOException { - return LinuxNative.httpResponseCode(((LinuxHttpConnection) connection).peer); + long peer = ((LinuxHttpConnection) connection).peer; + // A transfer that failed has no status: reporting the 0 libcurl leaves + // made ConnectionRequest read an empty answer instead of reaching its + // error path -- a refused connection or a timeout looked like success. + String failure = LinuxNative.httpFailure(peer); + if (failure != null) { + throw new IOException(failure); + } + return LinuxNative.httpResponseCode(peer); } @Override diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 51098d6289d..8399b7cd1ee 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -629,6 +629,18 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, public static native void httpSetHeader(long connection, String key, String value); + /// A method other than the GET or POST [#httpSetMethod] selects. + public static native void httpSetCustomMethod(long connection, String method); + + public static native void httpSetConnectTimeout(long connection, int millis); + + /// How long the transfer may sit without receiving anything. + public static native void httpSetReadTimeout(long connection, int millis); + + /// Why the transfer failed -- refused, timed out, reset -- or null when the + /// server answered, whatever its status. + public static native String httpFailure(long connection); + public static native int httpResponseCode(long connection); public static native String httpResponseMessage(long connection); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_net.c b/Ports/WindowsPort/nativeSources/cn1_windows_net.c index f5c6a7ce550..7c5a8b97854 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_net.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_net.c @@ -36,6 +36,8 @@ #include "cn1_windows.h" #include +#include +#include /* * A String[] return value needs the array class of java.lang.String; the @@ -57,6 +59,21 @@ typedef struct { BYTE* pendingBody; DWORD pendingLen; DWORD pendingCap; + /* ConnectionRequest.setHttpMethod, as asked for -- GET and POST included, + * which override the post flag; NULL when the request named none. */ + WCHAR* verb; + /* Every header line added, so a request handle recreated for a new verb + * gets them again: ConnectionRequest sets the method AFTER the headers, and + * WinHTTP fixes the verb when the handle is opened. */ + WCHAR** headers; + int headerCount; + int headerCap; + /* ConnectionRequest.setTimeout and setReadTimeout, in milliseconds; 0 keeps + * WinHTTP's default. */ + int connectTimeout; + int readTimeout; + /* The WinHTTP error of a transfer that failed, 0 when the server answered. */ + DWORD failure; } CN1Connection; /* ------------------------------------------------------------- internals */ @@ -69,7 +86,8 @@ typedef struct { */ static BOOL cn1NetCreateRequest(CN1Connection* conn) { DWORD flags = conn->https ? WINHTTP_FLAG_SECURE : 0; - const WCHAR* verb = conn->post ? L"POST" : L"GET"; + const WCHAR* verb = conn->verb != NULL ? conn->verb : (conn->post ? L"POST" : L"GET"); + int i; if (conn->request != NULL) { WinHttpCloseHandle(conn->request); conn->request = NULL; @@ -78,7 +96,14 @@ static BOOL cn1NetCreateRequest(CN1Connection* conn) { conn->path ? conn->path : L"/", NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); - return conn->request != NULL; + if (conn->request == NULL) { + return FALSE; + } + for (i = 0; i < conn->headerCount; i++) { + WinHttpAddRequestHeaders(conn->request, conn->headers[i], (DWORD)-1L, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + } + return TRUE; } /* @@ -96,20 +121,40 @@ static BOOL cn1NetEnsureSent(CN1Connection* conn) { return conn->responseReceived; } conn->sent = TRUE; + { + /* ConnectionRequest follows redirects itself, and only when asked to: + * WinHTTP's default of following them silently answered a request that + * set setFollowRedirects(false) with the redirect's target. */ + DWORD policy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; + WinHttpSetOption(conn->request, WINHTTP_OPTION_REDIRECT_POLICY, &policy, sizeof(policy)); + } + if (conn->connectTimeout > 0 || conn->readTimeout > 0) { + /* WinHTTP's defaults: no resolve limit, 60s connect, 30s send and receive. */ + WinHttpSetTimeouts(conn->request, 0, + conn->connectTimeout > 0 ? conn->connectTimeout : 60000, + 30000, + conn->readTimeout > 0 ? conn->readTimeout : 30000); + } - ok = WinHttpSendRequest(conn->request, - WINHTTP_NO_ADDITIONAL_HEADERS, 0, - conn->pendingLen > 0 ? (LPVOID)conn->pendingBody - : WINHTTP_NO_REQUEST_DATA, - conn->pendingLen, - conn->pendingLen, - 0); + { + /* An explicit GET sends no body, whatever the post flag buffered. */ + DWORD bodyLen = (conn->verb != NULL && wcscmp(conn->verb, L"GET") == 0) ? 0 : conn->pendingLen; + ok = WinHttpSendRequest(conn->request, + WINHTTP_NO_ADDITIONAL_HEADERS, 0, + bodyLen > 0 ? (LPVOID)conn->pendingBody + : WINHTTP_NO_REQUEST_DATA, + bodyLen, + bodyLen, + 0); + } if (!ok) { + conn->failure = GetLastError(); cn1WindowsLog("cn1NetEnsureSent: WinHttpSendRequest failed"); return FALSE; } ok = WinHttpReceiveResponse(conn->request, NULL); if (!ok) { + conn->failure = GetLastError(); cn1WindowsLog("cn1NetEnsureSent: WinHttpReceiveResponse failed"); return FALSE; } @@ -279,6 +324,54 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_httpSetMethod___long_boolean( } } +JAVA_VOID com_codename1_impl_windows_WindowsNative_httpSetCustomMethod___long_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2) { + CN1Connection* conn = (CN1Connection*)(intptr_t)__cn1Arg1; + WCHAR* verb; + UINT32 verbLen = 0; + if (conn == NULL || conn->sent || __cn1Arg2 == JAVA_NULL) { + return; + } + verb = cn1WinJavaStringToWide(threadStateData, __cn1Arg2, &verbLen); + if (verb == NULL) { + return; + } + free(conn->verb); + /* Kept even for GET and POST: an explicit one overrides the post flag, which + * a request may have set only for how its arguments are sent. */ + conn->verb = verb; + cn1NetCreateRequest(conn); +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_httpSetConnectTimeout___long_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_INT __cn1Arg2) { + CN1Connection* conn = (CN1Connection*)(intptr_t)__cn1Arg1; + if (conn != NULL) { + conn->connectTimeout = __cn1Arg2 > 0 ? __cn1Arg2 : 0; + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_httpSetReadTimeout___long_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_INT __cn1Arg2) { + CN1Connection* conn = (CN1Connection*)(intptr_t)__cn1Arg1; + if (conn != NULL) { + conn->readTimeout = __cn1Arg2 > 0 ? __cn1Arg2 : 0; + } +} + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_httpFailure___long_R_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1) { + CN1Connection* conn = (CN1Connection*)(intptr_t)__cn1Arg1; + char message[64]; + if (conn == NULL) { + return JAVA_NULL; + } + cn1NetEnsureSent(conn); + if (conn->failure == 0) { + return JAVA_NULL; + } + snprintf(message, sizeof(message), conn->failure == ERROR_WINHTTP_TIMEOUT + ? "the request timed out (WinHTTP error %lu)" : "the request failed (WinHTTP error %lu)", + (unsigned long)conn->failure); + return newStringFromCString(threadStateData, message); +} + JAVA_VOID com_codename1_impl_windows_WindowsNative_httpSetHeader___long_java_lang_String_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2, JAVA_OBJECT __cn1Arg3) { CN1Connection* conn = (CN1Connection*)(intptr_t)__cn1Arg1; WCHAR* key; @@ -310,7 +403,20 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_httpSetHeader___long_java_lan line[keyLen + 2 + valueLen] = 0; WinHttpAddRequestHeaders(conn->request, line, (DWORD)-1L, WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); - free(line); + /* Kept, and owned, for a handle recreated for a new verb. */ + if (conn->headerCount >= conn->headerCap) { + int cap = conn->headerCap > 0 ? conn->headerCap * 2 : 8; + WCHAR** grown = (WCHAR**)realloc(conn->headers, cap * sizeof(WCHAR*)); + if (grown != NULL) { + conn->headers = grown; + conn->headerCap = cap; + } + } + if (conn->headerCount < conn->headerCap) { + conn->headers[conn->headerCount++] = line; + } else { + free(line); + } } free(key); free(value); @@ -481,7 +587,7 @@ JAVA_INT com_codename1_impl_windows_WindowsNative_httpReadBody___long_byte_1ARRA JAVA_ARRAY_BYTE* data; DWORD bytesRead = 0; if (!cn1NetEnsureSent(conn)) { - return -1; + return -2; } if (__cn1Arg2 == JAVA_NULL || __cn1Arg4 <= 0) { return 0; @@ -489,8 +595,13 @@ JAVA_INT com_codename1_impl_windows_WindowsNative_httpReadBody___long_byte_1ARRA data = (JAVA_ARRAY_BYTE*)CN1_ARRAY_DATA(__cn1Arg2); if (!WinHttpReadData(conn->request, (LPVOID)(data + __cn1Arg3), (DWORD)__cn1Arg4, &bytesRead)) { + /* -2, not the -1 of end of stream: a body that stalls past the receive + * timeout fails here, after the response code was already read, and + * answering EOF handed the caller a silently truncated body. The error + * is kept for httpFailure, which the stream throws with. */ + conn->failure = GetLastError(); cn1WindowsLog("httpReadBody: WinHttpReadData failed"); - return -1; + return -2; } if (bytesRead == 0) { return -1; /* EOF */ @@ -543,6 +654,14 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_httpClose___long(CODENAME_ONE free(conn->host); free(conn->path); free(conn->pendingBody); + free(conn->verb); + { + int i; + for (i = 0; i < conn->headerCount; i++) { + free(conn->headers[i]); + } + free(conn->headers); + } free(conn); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index bbfde989e0e..baea4113f90 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2592,6 +2592,30 @@ public Object connect(String url, boolean read, boolean write) throws IOExceptio return new WindowsHttpConnection(c); } + @Override + public Object connect(String url, boolean read, boolean write, int timeout) throws IOException { + Object c = connect(url, read, write); + WindowsNative.httpSetConnectTimeout(((WindowsHttpConnection) c).peer, timeout); + return c; + } + + @Override + public void setHttpMethod(Object connection, String method) throws IOException { + WindowsNative.httpSetCustomMethod(((WindowsHttpConnection) connection).peer, method); + } + + /// True: [#setReadTimeout(Object, int)] is honoured, so a caller that checks + /// this before setting one gets the timeout it asked for. + @Override + public boolean isReadTimeoutSupported() { + return true; + } + + @Override + public void setReadTimeout(Object connection, int readTimeout) { + WindowsNative.httpSetReadTimeout(((WindowsHttpConnection) connection).peer, readTimeout); + } + @Override public void setHeader(Object connection, String key, String val) { WindowsNative.httpSetHeader(((WindowsHttpConnection) connection).peer, key, val); @@ -2687,7 +2711,14 @@ public InputStream getResourceAsStream(Class cls, String resource) { @Override public int getResponseCode(Object connection) throws IOException { - return WindowsNative.httpResponseCode(((WindowsHttpConnection) connection).peer); + long peer = ((WindowsHttpConnection) connection).peer; + // A transfer that failed has no status; answering -1 made ConnectionRequest + // read an empty answer instead of reaching its error path. + String failure = WindowsNative.httpFailure(peer); + if (failure != null) { + throw new IOException(failure); + } + return WindowsNative.httpResponseCode(peer); } @Override diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsInputStream.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsInputStream.java index 5e30b833e7a..14bd5ff5f77 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsInputStream.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsInputStream.java @@ -57,7 +57,14 @@ public int read(byte[] b, int off, int len) throws IOException { return closed ? -1 : 0; } if (http) { - return WindowsNative.httpReadBody(peer, b, off, len); + int n = WindowsNative.httpReadBody(peer, b, off, len); + if (n == -2) { + // The read failed -- a body that stalled past the read timeout -- + // which is an error, not the end of the body. + String failure = WindowsNative.httpFailure(peer); + throw new IOException(failure != null ? failure : "reading the response failed"); + } + return n; } return WindowsNative.fileRead(peer, b, off, len); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index d810068c0d6..3ad27c11040 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -679,6 +679,17 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, public static native void httpSetHeader(long connection, String key, String value); + /// A method other than the GET or POST [#httpSetMethod] selects. + public static native void httpSetCustomMethod(long connection, String method); + + public static native void httpSetConnectTimeout(long connection, int millis); + + public static native void httpSetReadTimeout(long connection, int millis); + + /// Why the transfer failed -- refused, timed out, reset -- or null when the + /// server answered, whatever its status. + public static native String httpFailure(long connection); + public static native int httpResponseCode(long connection); public static native String httpResponseMessage(long connection); @@ -689,6 +700,8 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, public static native String[] httpHeaderFieldNames(long connection); + /// Bytes read into `buffer`, -1 at the end of the body, or -2 when the read + /// failed; [#httpFailure(long)] then says why. public static native int httpReadBody(long connection, byte[] buffer, int offset, int length); public static native int httpWriteBody(long connection, byte[] buffer, int offset, int length); diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.h b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.h index c1955a4bdf6..3a8fb1bee23 100644 --- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.h +++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.h @@ -25,12 +25,14 @@ #import -@interface NetworkConnectionImpl : NSObject { +@interface NetworkConnectionImpl : NSObject { NSMutableURLRequest *request; int contentLength; int responseCode; NSDictionary* allHeaderFields; NSURLConnection *connection; + // The watchOS and tvOS slices' transport; nil elsewhere. + NSURLSession *urlSession; int chunkedStreamingLen; NSString* sslCertificates; NSMutableArray* pendingData; diff --git a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m index 45426f8a393..7b956674723 100644 --- a/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m +++ b/Ports/iOSPort/nativeSources/NetworkConnectionImpl.m @@ -76,10 +76,23 @@ - (void*)openConnection:(NSString*)url timeout:(int)timeout { - (void)connect { #if TARGET_OS_WATCH || TARGET_OS_TV - // NSURLConnection's synchronous delegate initializer is unavailable on - // watchOS (NSURLSession is the supported API). Networking via this legacy - // path is a no-op on the watch slice for now. + // NSURLConnection is unavailable on watchOS and tvOS, so these slices go + // through NSURLSession, with this object as the delegate and the same + // callbacks into Java the NSURLConnection path makes. This was a no-op, and + // every request on the watch and the TV waited forever for an answer. + // + // Ephemeral: no shared cookie store or cache. ConnectionRequest keeps its + // own cookies and sends them as headers (the request does not handle + // cookies), as it does on every other path. connection = nil; + NSURLSessionConfiguration* config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.HTTPShouldSetCookies = NO; + config.URLCache = nil; + urlSession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil]; +#ifndef CN1_USE_ARC + [urlSession retain]; +#endif + [[urlSession dataTaskWithRequest:request] resume]; #else dispatch_sync(dispatch_get_main_queue(), ^{ connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:YES]; @@ -166,6 +179,25 @@ - (JAVA_OBJECT) getSSLCertificates { //- (void) connection: (NSURLConnection*)connection willSendRequestForAuthenticationChallenge: (NSURLAuthenticationChallenge*)challenge { -(void) connection: (NSURLConnection*)connection willSendRequestForAuthenticationChallenge:(nonnull NSURLAuthenticationChallenge *)challenge { SecTrustRef trustRef = [[challenge protectionSpace] serverTrust]; + if (![self javaAcceptsTrust:trustRef]) { + // Java rejected the chain -- a per-request check or a guard pin mismatch. + // That veto applies to insecure requests too. + [challenge.sender cancelAuthenticationChallenge:challenge]; + return; + } + if (insecure) { + // Accepted despite whatever the OS thinks of the chain, which is what the + // caller asked for by setting it insecure. + [[challenge sender] useCredential:[NSURLCredential credentialForTrust:trustRef] forAuthenticationChallenge:challenge]; + return; + } + [challenge.sender performDefaultHandlingForAuthenticationChallenge:challenge]; +} + +/// Offers the server's chain to Java -- the per-request certificate check and the +/// guard's pins -- and answers whether it accepted. Shared by the NSURLConnection +/// and the NSURLSession paths, so pinning means the same on every slice. +-(BOOL) javaAcceptsTrust:(SecTrustRef)trustRef { SecTrustEvaluate(trustRef, NULL); NSMutableString* certs = [NSMutableString string]; // The chain is collected and offered to Java even for an insecure request. An @@ -206,19 +238,7 @@ -(void) connection: (NSURLConnection*)connection willSendRequestForAuthenticatio [sslCertificates release]; sslCertificates = [[NSString stringWithString:certs] retain]; #endif - if (!com_codename1_io_NetworkManager_checkCertificatesNativeCallback___int_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG connectionId)) { - // Java rejected the chain -- a per-request check or a guard pin mismatch. - // That veto applies to insecure requests too. - [challenge.sender cancelAuthenticationChallenge:challenge]; - return; - } - if (insecure) { - // Accepted despite whatever the OS thinks of the chain, which is what the - // caller asked for by setting it insecure. - [[challenge sender] useCredential:[NSURLCredential credentialForTrust:trustRef] forAuthenticationChallenge:challenge]; - return; - } - [challenge.sender performDefaultHandlingForAuthenticationChallenge:challenge]; + return com_codename1_io_NetworkManager_checkCertificatesNativeCallback___int_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG connectionId) ? YES : NO; } -(void)setConnectionId:(JAVA_INT)connId { @@ -410,6 +430,63 @@ - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { connectionReceivedData((BRIDGE_CAST void*)self, data); } +#if TARGET_OS_WATCH || TARGET_OS_TV +// ---- The NSURLSession path, for the slices with no NSURLConnection. Each mirrors +// the NSURLConnection callback of the same purpose above. + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask +didReceiveResponse:(NSURLResponse *)response + completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler { + [self connection:nil didReceiveResponse:response]; + completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask + didReceiveData:(NSData *)data { + connectionReceivedData((BRIDGE_CAST void*)self, data); +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task +willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)newRequest + completionHandler:(void (^)(NSURLRequest *))completionHandler { + // ConnectionRequest follows redirects itself, when asked to; see + // connection:willSendRequest:redirectResponse:. + completionHandler(nil); +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task +didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge + completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *))completionHandler { + if (![challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) { + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); + return; + } + SecTrustRef trustRef = [[challenge protectionSpace] serverTrust]; + if (![self javaAcceptsTrust:trustRef]) { + completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, nil); + return; + } + if (insecure) { + completionHandler(NSURLSessionAuthChallengeUseCredential, [NSURLCredential credentialForTrust:trustRef]); + return; + } + completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, nil); +} + +- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task +didCompleteWithError:(NSError *)error { + if (error != nil) { + connectionError((BRIDGE_CAST void*)self, [error localizedDescription]); + } else { + connectionComplete((BRIDGE_CAST void*)self); + } + connections--; + // A session holds its delegate strongly until it is invalidated, so this is + // also what lets this object go. + [session finishTasksAndInvalidate]; +} +#endif + - (void)connectionDidFinishLoading:(NSURLConnection *)connection { connectionComplete((BRIDGE_CAST void*)self); connections--; @@ -512,6 +589,10 @@ -(void)dealloc { [connection release]; connection = nil; } + if(urlSession != nil) { + [urlSession release]; + urlSession = nil; + } if (sslCertificates != nil) { [sslCertificates release]; sslCertificates = nil; diff --git a/docs/demos/backend/pom.xml b/docs/demos/backend/pom.xml index 74f7cea27de..7a291d7d99a 100644 --- a/docs/demos/backend/pom.xml +++ b/docs/demos/backend/pom.xml @@ -32,5 +32,25 @@ codenameone-core ${cn1.version} + + + com.codenameone + codenameone-backend-test + ${cn1.version} + provided + + + org.junit.jupiter + junit-jupiter-api + 5.9.3 + provided + + + org.mockito + mockito-core + 4.11.0 + provided + diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Uploads.java similarity index 54% rename from docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java rename to docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Uploads.java index 94f5cbc1e32..ca0bdb1451a 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/Uploads.java @@ -20,34 +20,30 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codenameone.developerguide.backend.beans; +package com.codenameone.developerguide.backend; -import com.codename1.backend.Backend; -import com.codename1.backend.Config; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RequestPart; +import com.codename1.backend.annotations.RestController; -import java.util.Properties; +import java.util.LinkedHashMap; +import java.util.Map; -/** How a test starts the build's wiring, as a Spring Boot test starts a context. */ -public final class WiringTest { - private WiringTest() { - } - - // `wiring` is `new BackendWiring()`: the class the build generates beside the - // entry point, which a test in the backend module can name directly. - static void run(Backend.Application wiring) throws Exception { -// tag::backend-wiring-test[] -Properties settings = new Properties(); -settings.setProperty("cn1.server.port", "0"); // any free port -Backend server = Backend.builder(Config.of(settings, "test")) - .quiet() - .application(wiring) - .start(); -try { - int port = server.getServer().getPort(); - // ... send requests to http://127.0.0.1: and assert on the answers ... -} finally { - server.stop(); -} -// end::backend-wiring-test[] +/** A form upload: a file part and a text field in one multipart request. */ +@RestController +public class Uploads { + // tag::backend-request-part[] + @PostMapping("/avatars") + public Map upload(@RequestPart("image") HttpServer.Part image, + @RequestParam("caption") String caption) { + Map out = new LinkedHashMap<>(); + out.put("file", image.getFilename()); + out.put("type", image.getContentType()); + out.put("bytes", image.getSize()); + out.put("caption", caption); + return out; } + // end::backend-request-part[] } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java index 44c8206792f..51f06838ef8 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AuditLog.java @@ -23,14 +23,14 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Propagation; -import com.codename1.backend.annotations.Service; import com.codename1.backend.annotations.Transactional; import java.io.IOException; // tag::backend-tx-requires-new[] -@Service +@Component public class AuditLog { private final DataSource db; diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java index 274a4830ac8..54141abaf2b 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Cart.java @@ -23,14 +23,14 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.annotations.Component; -import com.codename1.backend.annotations.SessionScope; +import com.codename1.backend.annotations.Scope; import java.util.ArrayList; import java.util.List; // tag::backend-session-bean[] @Component -@SessionScope +@Scope("session") public class Cart { private final List items = new ArrayList(); diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java index 0da7d11c442..fbd2b094938 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Checkout.java @@ -23,13 +23,13 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Qualifier; -import com.codename1.backend.annotations.Service; import java.util.List; // tag::backend-bean-candidates[] -@Service +@Component public class Checkout { private final PaymentProvider preferred; // the @Primary one: CardPayments diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java index 6fe2fd75027..e88b8cb8847 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Digest.java @@ -23,12 +23,12 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.Tasks; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Scheduled; -import com.codename1.backend.annotations.Service; import com.codename1.backend.annotations.ThreadKind; // tag::backend-scheduled-config[] -@Service +@Component public class Digest { @Scheduled(cron = "${digest.cron:0 0 7 * * MON-FRI}", zone = "America/New_York") public void weekdayMorning() { diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java index 8a6633c36e9..bb03eb5f839 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Imports.java @@ -23,15 +23,15 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Propagation; -import com.codename1.backend.annotations.Service; import com.codename1.backend.annotations.Transactional; import java.io.IOException; import java.util.List; // tag::backend-tx-nested[] -@Service +@Component public class Imports { private final DataSource db; diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java index e748362aafd..70c16e16eb1 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Orders.java @@ -23,13 +23,13 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.DataSource; -import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Transactional; import java.io.IOException; // tag::backend-tx-rules[] -@Service +@Component public class Orders { private final DataSource db; private final AuditLog audit; diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java index c1156744675..cdffe85ad14 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Outbox.java @@ -23,16 +23,16 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.PostConstruct; import com.codename1.backend.annotations.PreDestroy; -import com.codename1.backend.annotations.Repository; import java.io.IOException; import java.util.ArrayList; import java.util.List; // tag::backend-bean-lifecycle[] -@Repository +@Component public class Outbox { private final DataSource db; private final List pending = new ArrayList(); diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java index f28004946ff..2c37b1052af 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/ReminderService.java @@ -22,7 +22,7 @@ */ package com.codenameone.developerguide.backend.beans; -import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Transactional; import com.codename1.orm.session.Session; import com.codenameone.developerguide.backend.Reminder; @@ -30,7 +30,7 @@ import java.util.Date; // tag::backend-tx-session[] -@Service +@Component public class ReminderService { private final Session session; // the current transaction's session diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java index b92e226f9f1..ac4465cc87e 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Reports.java @@ -24,14 +24,14 @@ import com.codename1.backend.AsyncResult; import com.codename1.backend.annotations.Async; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Scheduled; -import com.codename1.backend.annotations.Service; import com.codename1.backend.annotations.ThreadKind; import java.util.concurrent.Future; // tag::backend-background[] -@Service +@Component public class Reports { @Async public Future monthly(String month) { diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java index 5e07bca107d..810c8127c5d 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/RequestClock.java @@ -23,11 +23,11 @@ package com.codenameone.developerguide.backend.beans; import com.codename1.backend.annotations.Component; -import com.codename1.backend.annotations.RequestScope; +import com.codename1.backend.annotations.Scope; // tag::backend-bean-request-scope[] @Component -@RequestScope +@Scope("request") public class RequestClock { private final long started = System.currentTimeMillis(); diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java index a29906c7c1b..8c38d9daa43 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/Signups.java @@ -24,14 +24,14 @@ import com.codename1.backend.DataSource; import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.PostConstruct; -import com.codename1.backend.annotations.Service; import com.codename1.backend.annotations.Transactional; import java.io.IOException; // tag::backend-bean-service[] -@Service +@Component public class Signups { private final DataSource db; diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java index c635175186c..5fe0555af52 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SmtpMailer.java @@ -22,12 +22,12 @@ */ package com.codenameone.developerguide.backend.beans; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.Profile; -import com.codename1.backend.annotations.Service; import com.codename1.backend.annotations.Value; // tag::backend-bean-value[] -@Service +@Component @Profile("!dev") public class SmtpMailer implements Mailer { @Value("${mail.host:localhost}") diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java index a5ca6fbef36..d5bd31422ad 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SupportTools.java @@ -22,12 +22,12 @@ */ package com.codenameone.developerguide.backend.beans; +import com.codename1.backend.annotations.Component; import com.codename1.backend.annotations.McpParam; import com.codename1.backend.annotations.McpTool; -import com.codename1.backend.annotations.Service; // tag::backend-mcp-tool[] -@Service +@Component public class SupportTools { private final Signups signups; diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/FakeGreetingsTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/FakeGreetingsTest.java new file mode 100644 index 00000000000..f433fe76e07 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/FakeGreetingsTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Primary; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.TestConfiguration; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; + +@BackendTest +class FakeGreetingsTest { + @Autowired + private MockMvc mvc; + + // tag::backend-test-configuration[] + @TestConfiguration + static class Fakes { + @Bean + @Primary + public Greetings formalGreetings() { + return new Greetings() { + @Override + public String greet(String name) { + return "Good evening, " + name; + } + }; + } + } + + @Test + void theControllerGetsTheTestBean() throws Exception { + mvc.perform(get("/greet/{name}", "Ada")) + .andExpect(jsonPath("$.greeting").value("Good evening, Ada")); + } + // end::backend-test-configuration[] +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApi.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApi.java new file mode 100644 index 00000000000..6e684b8967e --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApi.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** The controller the tests in this package exercise. */ +// tag::backend-test-api[] +@RestController +public class GreetingApi { + private final Greetings greetings; + + public GreetingApi(Greetings greetings) { + this.greetings = greetings; + } + + /** A JSON object holding the greeting. */ + @GetMapping("/greet/{name}") + public Map greet(@PathVariable("name") String name) { + Map out = new LinkedHashMap<>(); + out.put("greeting", greetings.greet(name)); + return out; + } +} +// end::backend-test-api[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApiTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApiTest.java new file mode 100644 index 00000000000..e7f65c66e98 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApiTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +// tag::backend-test-mockmvc[] +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MediaType; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.content; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +@BackendTest +class GreetingApiTest { + @Autowired + private MockMvc mvc; + + @Test + void greetsByName() throws Exception { + mvc.perform(get("/greet/{name}", "Ada")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.greeting").value("Hello, Ada")); + } + + @Test + void unknownRoutesAreNotFound() throws Exception { + mvc.perform(get("/nowhere")).andExpect(status().isNotFound()); + } +} +// end::backend-test-mockmvc[] diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableAsync.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/Greetings.java similarity index 76% rename from vm/backend/src/com/codename1/backend/annotations/EnableAsync.java rename to docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/Greetings.java index d6997d46a78..c484fe6e93a 100644 --- a/vm/backend/src/com/codename1/backend/annotations/EnableAsync.java +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/Greetings.java @@ -20,15 +20,15 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package com.codenameone.developerguide.backend.testing; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; +import com.codename1.backend.annotations.Component; -/// Accepted for familiarity and otherwise ignored: [Async] works without it. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface EnableAsync { +/** The service the controller in this package delegates to. */ +@Component +public class Greetings { + /** The greeting for one name. */ + public String greet(String name) { + return "Hello, " + name; + } } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/MockedGreetingsTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/MockedGreetingsTest.java new file mode 100644 index 00000000000..fc3b2db7bbd --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/MockedGreetingsTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockitoBean; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@BackendTest +class MockedGreetingsTest { + @Autowired + private MockMvc mvc; + + // tag::backend-test-mockito-bean[] + @MockitoBean + private Greetings greetings; + + @Test + void theControllerCallsTheMock() throws Exception { + when(greetings.greet("Ada")).thenReturn("Hi, Ada"); + mvc.perform(get("/greet/{name}", "Ada")) + .andExpect(jsonPath("$.greeting").value("Hi, Ada")); + verify(greetings).greet("Ada"); + } + // end::backend-test-mockito-bean[] +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ServedGreetingTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ServedGreetingTest.java new file mode 100644 index 00000000000..d0ce3276931 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ServedGreetingTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +// tag::backend-test-random-port[] +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.LocalServerPort; +import com.codename1.backend.test.ResponseEntity; +import com.codename1.backend.test.TestRestTemplate; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class ServedGreetingTest { + @Autowired + private TestRestTemplate rest; + + @LocalServerPort + private int port; + + @Test + void answersOverARealSocket() throws Exception { + assertTrue(port > 0); + ResponseEntity answer = rest.getForEntity("/greet/{name}", Map.class, "Ada"); + assertEquals(200, answer.getStatusCodeValue()); + assertEquals("Hello, Ada", answer.getBody().get("greeting")); + } +} +// end::backend-test-random-port[] diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/TunedGreetingTest.java b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/TunedGreetingTest.java new file mode 100644 index 00000000000..996a9066ff1 --- /dev/null +++ b/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/TunedGreetingTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.backend.testing; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// tag::backend-test-properties[] +@BackendTest(properties = {"app.greeting=Hi", "cn1.session.timeout=5"}) +// end::backend-test-properties[] +class TunedGreetingTest { + @Autowired + private MockMvc mvc; + + @Test + void readsTheBodyItself() throws Exception { + // tag::backend-test-and-return[] + String body = mvc.perform(get("/greet/{name}", "Ada")) + .andReturn().getResponse().getContentAsString(); + // end::backend-test-and-return[] + assertTrue(body.contains("Hello, Ada"), body); + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-observability.properties b/docs/demos/common/src/main/snippets/developer-guide/backend-observability.properties new file mode 100644 index 00000000000..84f09220a4c --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-observability.properties @@ -0,0 +1,6 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-observability-properties-001[] +cn1.management.enabled=true +cn1.management.path=/ops +// end::backend-observability-properties-001[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-testing.sh b/docs/demos/common/src/main/snippets/developer-guide/backend-testing.sh new file mode 100644 index 00000000000..98d7b8411df --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-testing.sh @@ -0,0 +1,14 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-testing-bash-001[] +mvn -pl backend -Dcodename1.platform=backend test +// end::backend-testing-bash-001[] + +// tag::backend-testing-bash-002[] +mvn -pl backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true +// end::backend-testing-bash-002[] + +// tag::backend-testing-bash-003[] +./gradlew test # on this JVM +./gradlew backendTest # as a native binary +// end::backend-testing-bash-003[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-testing.xml b/docs/demos/common/src/main/snippets/developer-guide/backend-testing.xml new file mode 100644 index 00000000000..1d072aa49a4 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-testing.xml @@ -0,0 +1,44 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-testing-xml-001[] + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + +// end::backend-testing-xml-001[] + +// tag::backend-testing-xml-002[] + + cn1-process-test-annotations + process-test-classes + + process-test-annotations + + + + cn1-compiled-tests + test + + backend-test + + +// end::backend-testing-xml-002[] + +// tag::backend-testing-xml-003[] + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + false + + +// end::backend-testing-xml-003[] diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-web.properties b/docs/demos/common/src/main/snippets/developer-guide/backend-web.properties new file mode 100644 index 00000000000..4f4ce443bd4 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-web.properties @@ -0,0 +1,7 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-web-properties-001[] +cn1.cors.allowedOrigins=https://app.example.com +cn1.cors.allowCredentials=true +cn1.cors.exposedHeaders=X-Request-Id +// end::backend-web-properties-001[] diff --git a/docs/demos/gradle/gradle-project-workflow.sh b/docs/demos/gradle/gradle-project-workflow.sh index 2b175a5a72f..b06e84ddd1d 100644 --- a/docs/demos/gradle/gradle-project-workflow.sh +++ b/docs/demos/gradle/gradle-project-workflow.sh @@ -65,6 +65,8 @@ mvn com.codenameone:codenameone-maven-plugin:VERSION:generate-app-project \ ./gradlew addBackend # create backend/ once CN1_PROFILE=dev ./gradlew :backend:runBackend # run it on this JVM ./gradlew :backend:backendPackage # build the native binary +./gradlew :backend:test # run its tests on this JVM +./gradlew :backend:backendTest # and as a native binary # end::gradle-backend[] # tag::gradle-backend-only[] diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index e7ddbe7f11e..e93c56bac6e 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -27,8 +27,8 @@ include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/b include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/SignupApi.java[tag=backend-bean-controller,indent=0] ---- -`@Service`, `@Component` and `@Repository` mark a bean; a `@RestController` or -`@WebSocketMapping` class is one too. A class with one constructor gets that +`@Component` marks a bean; a `@RestController`, `@WebSocketMapping` or +`@Configuration` class is one too. A class with one constructor gets that constructor; with several, the one marked `@Autowired`. Fields and setters are injected only when marked `@Autowired`, and a private field is fine. @@ -45,7 +45,7 @@ request: ---- constructor parameter 1 of com.example.SignupApi needs a com.example.Signups, and no bean has that type. Annotate the implementing class @Component, -@Service or @Repository, or declare a @Bean method returning one. +or declare a @Bean method returning one. ---- Two beans of one type are an error too, until one is marked `@Primary` or the @@ -65,10 +65,6 @@ that needs it. These annotations make a class one: | `@Component` | Anything the application wires together. -| `@Service`, `@Repository` -| The same as `@Component`. They exist so a class can say which layer it - belongs to, as in Spring. - | `@Configuration` | A class whose `@Bean` methods produce beans, described under <>. It's a bean itself, so it's injected too. @@ -156,10 +152,10 @@ and they're injected the same way: <>. | `HttpServer.Request` -| The current request. Only a `@RequestScope` bean can take it. +| The current request. Only a `@Scope("request")` bean can take it. | `HttpSession` -| The current session. Only a `@RequestScope` bean can take it. +| The current session. Only a `@Scope("request")` bean can take it. |=== === Configuration values @@ -293,8 +289,8 @@ when the session ends for a session-scoped one. === Scopes A bean is a singleton unless it says otherwise. `@Scope("prototype")` gives each -injection point its own instance. `@RequestScope` and `@SessionScope` give one -per HTTP request or session; injected into a singleton, such a bean is reached +injection point its own instance. `@Scope("request")` and `@Scope("session")` +give one per HTTP request or session; injected into a singleton, such a bean is reached through a small generated subclass that finds the current request's instance on each call, so the class can't be final and needs a constructor that takes no arguments. `@Lazy` builds a bean the first time it's used, through the same kind @@ -313,11 +309,11 @@ exist at all, so the expensive part of setting a bean up belongs in its | A new instance for each injection point. Two fields of type `Ticket` in one class get two tickets. -| `@RequestScope`, or `@Scope("request")` +| `@Scope("request")` | One per HTTP request, built the first time the request uses it and destroyed when the request ends. -| `@SessionScope`, or `@Scope("session")` +| `@Scope("session")` | One per HTTP session, kept by the server for as long as the session lives and destroyed when it ends. See <>. @@ -401,8 +397,8 @@ compiles, and each refusal names the class and member at fault: | Message fragment | What to do | `needs a X, and no bean has that type` -| Annotate the implementation `@Component`, `@Service` or `@Repository`, or add - a `@Bean` method returning one. +| Annotate the implementation `@Component`, or add a `@Bean` method returning + one. | `could receive any of ...` | Mark one `@Primary`, or inject with `@Qualifier("name")`. @@ -422,7 +418,7 @@ compiles, and each refusal names the class and member at fault: constructor with no arguments. | `asks for the current request` -| Only a `@RequestScope` bean can inject the request. Take it as a parameter of +| Only a `@Scope("request")` bean can inject the request. Take it as a parameter of the handler method instead. | `Two beans are named` @@ -453,10 +449,6 @@ differences all follow from doing the work at build time: Constructor injection is a constructor, so a unit test builds the object with fakes and needs nothing else. For the wired server -- what `@SpringBootTest` -does in Spring -- a test in the backend module starts the generated wiring on a -free port: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/WiringTest.java[tag=backend-wiring-test,indent=0] ----- +does in Spring -- annotate a test class with `@BackendTest`. It starts the +application with the build's wiring, lets the test replace beans, and sends +requests through `MockMvc` or over a real port. <> covers it. diff --git a/docs/developer-guide/Backend-MCP.asciidoc b/docs/developer-guide/Backend-MCP.asciidoc index 51c4bf4a90d..5fe1ba68560 100644 --- a/docs/developer-guide/Backend-MCP.asciidoc +++ b/docs/developer-guide/Backend-MCP.asciidoc @@ -93,12 +93,11 @@ A server restarted in a test serves the tools its own beans provide and nothing a previous one registered. IMPORTANT: A packaged server has no MCP code at all unless its build asks for it: -a class with an `@McpTool` method, `@EnableMcpServer` on a class, or -`cn1.mcp.enabled=true` in a properties file. Without any of those, the generated +a class with an `@McpTool` method, or `cn1.mcp.enabled`, `cn1.mcp.path` or +`cn1.mcp.allowedOrigins` in a properties file. Without any of those, the generated entry point never names the endpoint and the translator leaves it out of the binary, development tools included; `cn1:backend` builds the development tools in -for the development run only. `@EnableMcpServer(path = ..., allowedOrigins = ...)` -sets the two keys it names, and a server built with the endpoint turns it off at +for the development run only. A server built with the endpoint turns it off at start-up with `cn1.mcp.enabled=false`. === Tools for developing the server diff --git a/docs/developer-guide/Backend-Observability.asciidoc b/docs/developer-guide/Backend-Observability.asciidoc index 6132c89b28c..57949846076 100644 --- a/docs/developer-guide/Backend-Observability.asciidoc +++ b/docs/developer-guide/Backend-Observability.asciidoc @@ -295,15 +295,14 @@ still fail to start. IMPORTANT: A packaged server has no management code at all unless its build asks for it. The generated entry point is the only code that names the endpoints, and -it names them only when the build sees `@EnableManagement` on a class, or -`cn1.management.enabled=true` in `application.properties` or a profile's file. -Without either, the translator leaves the endpoints out of the binary, so there's +it names them only when the build sees `cn1.management.enabled=true` in +`application.properties` or a profile's file. Without it, the translator leaves the endpoints out of the binary, so there's no `/manage` to find or to misconfigure, whatever profile the server later runs under. The development run through `cn1:backend` always builds them in. -[source,java] +[source,properties] ---- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java[tag=backend-enable-management,indent=0] +include::../demos/common/src/main/snippets/developer-guide/backend-observability.properties[tag=backend-observability-properties-001,indent=0] ---- A server built with them can still turn them off at start-up with @@ -321,5 +320,4 @@ counter, so `http.server.request.duration` is scraped as `http_server_request_duration`. The management endpoints are served before the application's routes, so a -catch-all route can't answer them. A running server's managed beans are also -available to code: inject `Backend` and call its `getManagedBeans()`. +catch-all route can't answer them. diff --git a/docs/developer-guide/Backend-Scheduling.asciidoc b/docs/developer-guide/Backend-Scheduling.asciidoc index c98d5c78545..626b79a5a33 100644 --- a/docs/developer-guide/Backend-Scheduling.asciidoc +++ b/docs/developer-guide/Backend-Scheduling.asciidoc @@ -55,7 +55,7 @@ never run the body at once. `@Async` on a `@Scheduled` method is a build error: it would end the run as soon as the work was queued, so runs could overlap and a lock be released early; `@Scheduled` already runs off the request thread, on the thread its `thread` -attribute picks. `@Async` on a `@RequestScope` or `@SessionScope` bean builds with +attribute picks. `@Async` on a request- or session-scoped bean builds with a warning, as it runs in Spring: the task calls the bean itself, which is destroyed when its request or session ends, possibly before the task runs. diff --git a/docs/developer-guide/Backend-Sessions.asciidoc b/docs/developer-guide/Backend-Sessions.asciidoc index ac45cac4290..8a0da1bd833 100644 --- a/docs/developer-guide/Backend-Sessions.asciidoc +++ b/docs/developer-guide/Backend-Sessions.asciidoc @@ -132,14 +132,6 @@ browser closes; how long the session lasts is the server's decision. Anything else stops the server at start-up. |=== -`@SessionConfig` sets the same keys in source -- `@SessionConfig(store = "db", -timeoutSeconds = 3600)` -- as the bottom layer under the properties files, and -refuses at build time a value this table says would stop the server; see -<>. - -An injected `Backend` returns the server's sessions from `getSessions()`, whose -`getStore()` answers the store in use. - === Where sessions are kept `cn1.session.store=memory` keeps sessions in the server's own memory. It needs @@ -170,7 +162,7 @@ cookie is treated as having no session at all. === Session-scoped beans -A bean marked `@SessionScope` exists once per session, which suits state that +A bean marked `@Scope("session")` exists once per session, which suits state that belongs to a client and has behaviour of its own: [source,java] diff --git a/docs/developer-guide/Backend-Testing.asciidoc b/docs/developer-guide/Backend-Testing.asciidoc new file mode 100644 index 00000000000..6451bb84887 --- /dev/null +++ b/docs/developer-guide/Backend-Testing.asciidoc @@ -0,0 +1,310 @@ +[[backend-testing]] +== Backend testing + +A backend module's tests are JUnit 5 classes in `src/test/java`, written against +the API a Spring Boot test uses. `@BackendTest` starts the module's server, wired +the way it ships, and injects what the test asks for. `MockMvc` sends a request +to that server inside the test process, and `TestRestTemplate` sends one over a +real port. A test can add beans of its own and replace the application's. This +chapter covers each of those, and then the same tests compiled into a native +binary. + +Because the names follow Spring's, a test ported from Spring Boot mostly needs +its imports changed. The differences are listed at the end of the chapter. + +=== Setting up a module for tests + +A backend module created from the project template is already set up. In +another module, add the test library and JUnit 5 to the `pom.xml`: + +[source,xml] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-testing.xml[tag=backend-testing-xml-001,indent=0] +---- + +Then bind two more goals of the Codename One plugin beside `process-annotations`, +and give each test class a fresh JVM: + +[source,xml] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-testing.xml[tag=backend-testing-xml-002,indent=0] +---- + +[source,xml] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-testing.xml[tag=backend-testing-xml-003,indent=0] +---- + +`process-test-annotations` generates the code each test class runs against. +`backend-test` runs the compiled tests, and does nothing unless they're asked +for. A server process runs one backend at a time, so `reuseForks` set to +`false` keeps the state of one test class's server away from the next class. + +Run the tests with: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-testing.sh[tag=backend-testing-bash-001,indent=0] +---- + +A Gradle backend gets the same setup from the Codename One plugin: the test +library, JUnit 5, a fresh JVM per test class and the test pass all come with +it. The standard `test` task runs the tests, and `backendTest` runs them as a +native binary, as described below: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-testing.sh[tag=backend-testing-bash-003,indent=0] +---- + +=== Testing a controller with MockMvc + +This controller answers a greeting as JSON, using a `Greetings` bean: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApi.java[tag=backend-test-api,indent=0] +---- + +A test of it reads the way a Spring MVC test does: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApiTest.java[tag=backend-test-mockmvc,indent=0] +---- + +`@BackendTest` starts the application with the same beans and the same wiring as +the server the build produces. `@Autowired` fields of the test class are filled +from its beans, and `MockMvc` is one of them. `perform` runs a request through +the server's dispatch -- the router, the sessions, the scoped beans and the error +handling -- without opening a socket. Each `andExpect` checks one thing about the +response and fails the test with a message saying what it saw. + +The request builders are static methods of `MockMvcRequestBuilders`: + +[cols="2,3", options="header"] +|=== +| Method | Builds + +| `get`, `post`, `put`, `patch`, `delete`, `head`, `options` +| A request with that method. A URI template's `{name}` placeholders are filled, + encoded, from the arguments that follow it. + +| `request(HttpMethod, uri, ...)` +| A request with any method. + +| `multipart(uri, ...)` +| A `multipart/form-data` POST. `.file(...)` adds a file part and `.param(...)` + adds a form field. +|=== + +Each builder takes `.header`, `.param` (a query parameter), `.content` (the +body, as a string or bytes), `.contentType`, `.accept`, `.characterEncoding` and +`.cookie`. + +The matchers are static methods of `MockMvcResultMatchers`: + +[cols="2,3", options="header"] +|=== +| Matcher | Checks + +| `status()` +| The status: `isOk()`, `isCreated()`, `isNotFound()` and the other names, a + class with `is4xxClientError()` and similar, or a number with `is(int)`. + +| `content()` +| The body with `string`, `bytes` or `json` (a JSON body holding every field of + the expected one), and the content type with `contentType` or + `contentTypeCompatibleWith`. + +| `jsonPath(expression)` +| A value in a JSON body: `value`, `exists`, `doesNotExist`, `isArray`, + `isEmpty` and the type checks. The expression supports `$`, `.name`, + `['name']`, `[index]` (negative counts from the end), `[*]`, `.*` and a final + `.length()`. + +| `header()` +| A response header: `string`, `longValue`, `exists` or `doesNotExist`. + +| `cookie()` +| A cookie the response sets: `value`, `exists` or `doesNotExist`. + +| `redirectedUrl(url)` +| The `Location` of a redirect. +|=== + +`andExpectAll` checks several matchers and reports every one that failed rather +than stopping at the first. `andDo(print())` writes the request and response to +standard output. `andReturn()` gives the result, for assertions no matcher +covers: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/TunedGreetingTest.java[tag=backend-test-and-return,indent=0] +---- + +=== Testing over a real port + +`MockMvc` skips the socket, the HTTP parser and the response writer. To test +through them, serve the application on a free port: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ServedGreetingTest.java[tag=backend-test-random-port,indent=0] +---- + +With `RANDOM_PORT`, the test injects `TestRestTemplate`, an HTTP client for the +running server, and `@LocalServerPort` holds the port it chose. The client +resolves paths against the server, so a test never builds a URL. It encodes a +body as JSON and decodes an answer into a `String`, a `byte[]`, a `Map` or a +`List`. `getForObject` returns the body, and `getForEntity` returns a +`ResponseEntity` with the status and headers too. `postForEntity`, `put`, +`delete` and `exchange` cover the other methods, and `withBasicAuth` returns a +client that signs every request in. A status of 4xx or 5xx is an answer, not an +exception, so a test can assert on it. + +The client is the backend's own, so a compiled test run sends its requests from +the native binary's HTTP stack. + +[cols="1,3", options="header"] +|=== +| `webEnvironment` | The application is + +| `MOCK` (the default) | Reached through `MockMvc`, in the test's process. +| `RANDOM_PORT` | Served on a free port of the loopback interface. +| `DEFINED_PORT` | Served on the configured `cn1.server.port`. +| `NONE` | Started for its beans only; the test calls them directly. +|=== + +Every environment but `DEFINED_PORT` listens on a free loopback port, so test +runs never collide with each other or with a server running on the machine. + +=== Settings and profiles + +A test runs with the module's `application.properties`, then the profile's file, +then the settings the annotation names: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/TunedGreetingTest.java[tag=backend-test-properties,indent=0] +---- + +The settings a test names win over everything, the environment included, unlike +a deployed server, where the environment has the last word. A shell that exports +`PORT` or `DATABASE_URL` therefore doesn't move a test off its free port or its +test database. + +The profile is `test` unless `profile` names another, and `test` is a +development profile: unless the test or the profile's file names a database, the +application gets an in-memory SQLite one, and the generated daos create their +tables. A test therefore starts from an empty database with no setup. + +Starting an application costs time, so test classes share one. Classes with the +same test beans, settings, profile and web environment use the same running +application, much as Spring caches a context. A class whose configuration +differs stops it and starts its own. State that a test leaves in a bean or the +database is visible to the next test that shares the application, so a test +must not depend on the order tests run in. + +=== Replacing a bean + +A static nested class marked `@TestConfiguration` adds its `@Bean` methods to the +test's application. Marked `@Primary`, a test bean wins every injection of its +type, which replaces the application's bean without a mocking library: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/FakeGreetingsTest.java[tag=backend-test-configuration,indent=0] +---- + +A top-level `@TestConfiguration` class is added by listing it in +`@BackendTest(classes = ...)`. A test bean is an ordinary singleton: it receives +its own injections, and the test can `@Autowired` it. It can't be request-scoped, +session-scoped or lazy, and it can't carry `@Transactional`, `@Async` or the +other annotations the build weaves; the build says so when one does. + +=== Mocking a bean with Mockito + +With `org.mockito:mockito-core` on the test classpath, `@MockitoBean` replaces +every bean of the field's type with a Mockito mock and injects it: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/MockedGreetingsTest.java[tag=backend-test-mockito-bean,indent=0] +---- + +The mock is reset after every test. A controller can't be mocked, because it's +what the test is exercising. Mockito builds classes while it runs, which a native +binary can't do, so `@MockitoBean` tests run only on the JVM. A `@Primary` test +bean works in both. + +=== Running the tests compiled + +The server ships as a native binary, and the tests can run that way too: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-testing.sh[tag=backend-testing-bash-002,indent=0] +---- + +After Surefire runs the tests on the JVM, the `backend-test` goal compiles the +same test classes with the application into one native binary, the way +`cn1:backend-package` builds the server, then runs it. The test binary has the +generated wiring, the woven methods and the natives of the shipped server, so a +difference between the two runtimes fails a test rather than a deployment. + +The binary has no JUnit and no reflection. The build finds the tests in the +compiled classes and generates the calls JUnit would make: `@BeforeAll`, +`@BeforeEach`, `@Test`, `@AfterEach` and `@AfterAll`, with `@Disabled` and +assumptions honored. The tests compile against a subset of JUnit 5 that the test +library provides for the purpose. It covers those annotations, `@DisplayName` +and `@Tag`, `Assumptions`, and the `Assertions` methods: `assertEquals`, +`assertNotEquals`, `assertTrue`, `assertFalse`, `assertNull`, `assertNotNull`, +`assertSame`, `assertNotSame`, `assertArrayEquals`, `assertIterableEquals`, +`assertInstanceOf`, `assertThrows`, `assertThrowsExactly`, `assertDoesNotThrow`, +`assertAll` and `fail`. + +The compiled run picks the same test classes as the JVM run: under Maven, the +ones Surefire's includes and excludes select (its defaults, unless the pom names +others), or the ones `-Dtest` names, down to the methods a `Class#method` entry +selects; under Gradle, every test class. Tag selection isn't applied to it. + +`@BackendTest` is inherited, so an abstract base class can carry it with the +shared fields and tests. Each concrete subclass gets its own context, and tests +declared as default methods of an interface the class implements run too. + +The results go to `target/surefire-reports` as `TEST--compiled.xml`, +beside the JVM run's reports, and a failed test fails the build. + +A compiled run has these limits: + +* **Mockito.** A test class whose code uses Mockito is left out of the binary, and + a class with a `@MockitoBean` field is reported as skipped. Pass + `-Dcn1.backend.compiledTests.strict=true` to make either one fail the build. +* **Java tests only.** The binary is compiled from Java test sources. A module + with Kotlin tests runs them on the JVM, and the compiled run reports them by + name and stops rather than running without them. +* **Toolchain.** The binary needs what `cn1:backend-package` needs: clang, and + the OpenSSL, libcurl and nghttp2 headers. It builds on Linux and macOS. On + Windows, which has no native backend, the goal reports that and does nothing. +* **Time.** Building the binary takes about as long as packaging the server, so + it suits CI better than an edit-and-run loop. + +=== Differences from Spring Boot + +* **Packages.** The annotations and classes live in `com.codename1.backend.test`, + and `@Autowired` is `com.codename1.backend.annotations.Autowired`. +* **`@BackendTest` instead of `@SpringBootTest`.** Its options are the + web environment, `properties`, `profile` and `classes`. `@WebMvcTest`-style + slices don't exist; every test runs the whole application. +* **Wiring at build time.** The test's application is generated by the build + rather than assembled when the test starts. An injection that can't be + satisfied fails the build. +* **One application at a time.** Spring can keep several contexts cached. Here + a class with a different configuration stops the running application before + it starts its own. +* **A listener in every environment.** `MOCK` and `NONE` also start the + application listening on a free loopback port, because its server hosts the + threads its tasks run on. +* **The JSON path subset.** `jsonPath` supports the expressions listed above, + without filters or functions other than `length()`. diff --git a/docs/developer-guide/Backend-Web.asciidoc b/docs/developer-guide/Backend-Web.asciidoc index ed80a3b32a9..a6bbd038b5d 100644 --- a/docs/developer-guide/Backend-Web.asciidoc +++ b/docs/developer-guide/Backend-Web.asciidoc @@ -51,9 +51,13 @@ one annotation: | `String`, or a numeric or `boolean` primitive | `@RequestParam("q")` -| a query parameter +| a query parameter, or a field of a form body | `String`, or a numeric or `boolean` primitive +| `@RequestPart("file")` +| one part of a `multipart/form-data` body +| `HttpServer.Part`, `byte[]`, or `String` for a text field + | `@RequestHeader("Idempotency-Key")` | a request header | `String`, or a numeric or `boolean` primitive @@ -204,6 +208,102 @@ request on it, so a handler that keeps the body beyond its own call copies what needs. That's the price of a router that allocates nothing for a route without path variables. +==== Forms and file uploads + +A browser form posts its fields as `application/x-www-form-urlencoded`, or as +`multipart/form-data` when it carries a file. `@RequestParam` reads a field of +either kind of form the way it reads a query parameter, so a handler doesn't +care how the client encoded it. The query string wins when both have the name, +as it does in a servlet container. `@RequestPart` binds one part of a multipart +body, which is the way to a file: + +[source,java] +---- +include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/Uploads.java[tag=backend-request-part,indent=0] +---- + +An `HttpServer.Part` has the part's name, its file name when it's a file, its +content type, its own headers and its bytes. A body that isn't well-formed +multipart is answered 400 before the method runs, and so is a request without a +required part. A handler that takes the request itself reads the same parts with +`getParts()` and `getPart(name)`, and reads a field with `param(name)`. + +The body limit applies to the whole multipart request, files included: 8 MB, as +for any other body. A body that isn't text -- an upload, an image, +`application/octet-stream` -- reaches the handler as bytes through +`getBodyBytes()`. `getBody()` returns text only for a text type, JSON, XML or a +form, after checking that it's valid UTF-8, and refuses a binary body rather +than hand the handler garbled characters. + +==== Compression + +`cn1.server.compression.enabled=true` turns on gzip for responses, with the two +other settings Spring Boot has for it. A response is compressed when the client +sends `Accept-Encoding: gzip`, its type is in `cn1.server.compression.mimeTypes` +(text, JSON, JavaScript and XML by default), and its body is at least +`cn1.server.compression.minResponseSize` bytes (2048 by default). A static file +is left unchanged, because it's sent straight from the page cache, and so is a +response whose handler set a `Content-Encoding` of its own. A range answer (`206` or any +response with `Content-Range`) is never compressed either, because its offsets +describe the uncompressed bytes, and neither is a response with a strong `ETag`, +which names its exact bytes, as Tomcat leaves one by default. Compression is off +by default because it trades processor time for bytes, and a server behind a +proxy that already compresses would pay for it twice. + +A request body sent with `Content-Encoding: gzip` is decompressed before the +handler sees it, whether responses are compressed or not. It's held to the same +8 MB limit after decompression, so a small compressed body can't expand past it. +A request in any other encoding is answered 415. + +==== Cross-origin requests + +A browser refuses to let a page read a response from another origin -- another +scheme, host or port -- unless the server says it may. A web app served from one +host that calls an API on another needs the API to say so, and the `cn1.cors` +settings do: + +[source,properties] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-web.properties[tag=backend-web-properties-001,indent=0] +---- + +[cols="2,3", options="header"] +|=== +| Key | What it sets + +| `cn1.cors.allowedOrigins` +| The origins that may call the server, separated by commas, or `*` for any. + Each is `scheme://host[:port]` with no path, as a browser sends it in + `Origin`; the server refuses to start with anything else. Cross-origin + requests are refused while this is unset. + +| `cn1.cors.allowedMethods` +| The methods a cross-origin request may use, preflighted or not. All the + common ones by default. + +| `cn1.cors.allowedHeaders` +| The request headers a preflight allows, `*` by default. + +| `cn1.cors.exposedHeaders` +| Response headers beyond the basic ones that the page may read. + +| `cn1.cors.allowCredentials` +| Whether the browser sends cookies and the page may read the answer. A browser + refuses this with a `*` origin, so the server refuses that combination when + it starts. + +| `cn1.cors.maxAgeSeconds` +| How long a browser may cache a preflight answer, 1800 seconds by default. +|=== + +The server answers a preflight `OPTIONS` request itself, and adds the headers to +every response to an allowed origin. A cross-origin request from an origin not on +the list, or with a method not on it, is refused with a 403 before any handler +runs, as Spring refuses it; a request whose `Origin` is the server itself isn't a +cross-origin request. A +handler that maps `OPTIONS` or sets `Access-Control-Allow-Origin` itself keeps its +own policy, which is how the MCP endpoint keeps the stricter one it has. + ==== Serving static files `cn1.static.root` names a directory, and the server answers requests under @@ -280,7 +380,7 @@ injected the same way, and two endpoints claiming one path is a build error rath than something registration order decides. Write the path's characters out: an escaped character such as `%61` in the mapping is a build error too, since an upgrade is matched against the decoded path, which an escaped mapping never equals. -An endpoint that injects a `@RequestScope` or `@SessionScope` bean, directly or +An endpoint that injects a request- or session-scoped bean, directly or through a singleton, builds with a warning: its callbacks run outside any HTTP request, so there's no request or session to find the bean in, and using it there throws `IllegalStateException`, as in Spring. Keep per-connection state in the diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index 5d6bf4f3d9e..13baa367b4b 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -332,6 +332,10 @@ there is nothing next to the binary. | `cn1.server.port` | The port. Also read from `PORT`. +| `cn1.server.address` +| The address to listen on. Every interface by default; `127.0.0.1` keeps the + server reachable only from the machine it runs on. + | `cn1.server.workers` | The size of the request thread pool, 16 by default. Under virtual threads it no longer bounds the requests in flight; see <>. @@ -383,55 +387,20 @@ The chapters that follow add their own keys -- `cn1.session.*`, `cn1.task.executor.*`, `cn1.management.*`, `cn1.mcp.*` and `cn1.otel.*` -- and list each one where it's described. -==== Settings in source +==== Settings compiled in A server that ships as one executable often has no properties file beside it, so -the common settings can also be written as annotations, on any class in the -backend module: - -[source,java] ----- -include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java[tag=backend-settings-annotations,indent=0] ----- - -Each attribute is one key -- `port` is `cn1.server.port`, `store` is -`cn1.session.store` -- and the build compiles its value in as the bottom layer of -the configuration, below `application.properties`. A properties file or the -environment still changes it without a rebuild, and an attribute left out sets -nothing. A value the server would refuse at start-up, such as a session store -that isn't `memory` or `db`, is a build error naming the class instead, and so -are two classes that give one key different values. - -[cols="2,3", options="header"] -|=== -| Annotation | Keys - -| `@ServerConfig` -| `cn1.server.port`, `workers`, `backlog`, `shutdownTimeoutMillis` - -| `@DataSourceConfig` -| `cn1.datasource.url`, `pool.size`, `pool.borrowTimeoutMillis`, - `busyTimeoutMillis` - -| `@SessionConfig` -| `cn1.session.store`, `timeout`, `cookie`, `same-site`, `secure`, `namespace` - -| `@StaticFilesConfig` -| `cn1.static.root`, `prefix`, `index`, `cacheControl` - -| `@OpenTelemetry` -| turns tracing on and names the service; `cn1.otel.endpoint`, `protocol`, - `sampler`, `sampler.arg` - -| `@EnableManagement` -| builds the management endpoints in; `cn1.management.enabled`, `path` - -| `@EnableMcpServer` -| builds the MCP endpoint in; `cn1.mcp.path`, `allowedOrigins` -|=== - -Secrets -- a token, a password inside a database URL -- don't belong in source. -Name them as `${NAME}` in the annotation or leave them to the environment, which -is where the server reads a token from. +the build compiles the module's `application.properties` into the server as the +bottom layer of its configuration. The binary starts with the committed settings +and nothing next to it, and a properties file or the environment present at run +time still changes any key without a rebuild. + +Only the base file is compiled in. A profile's file describes a different +deployment, so `application-dev.properties` stays a file the dev profile reads +rather than becoming a production binary's defaults. + +Secrets -- a token, a password inside a database URL -- don't belong in a +committed file. Name them as `${NAME}`, which is resolved from the environment +when the value is read, or leave the key to the environment entirely. Handlers that need any of this get it from the entry point the build writes. diff --git a/docs/developer-guide/Gradle-Project-Workflow.asciidoc b/docs/developer-guide/Gradle-Project-Workflow.asciidoc index 0b7549a78b9..95e963e7c31 100644 --- a/docs/developer-guide/Gradle-Project-Workflow.asciidoc +++ b/docs/developer-guide/Gradle-Project-Workflow.asciidoc @@ -231,6 +231,10 @@ Gradle runs one Codename One build at a time, even when a command line names sev | `cn1:backend-package` | Builds a backend as a single native binary. +| `backendTest` +| `test -Dcn1.backend.compiledTests=true` +| Runs a backend's tests as a native binary. The standard `test` task runs them on this JVM. + | `cn1Update` | `cn1:update` | Updates the plugin version in `settings.gradle.kts` and the build client. @@ -350,7 +354,7 @@ Java and Kotlin classes in one project can call each other. Both are checked by include::../demos/gradle/gradle-project-workflow.sh[tag=gradle-backend,indent=0] ---- -A backend-only project has the same bootstrap with the server at the root: `application.properties`, `application-dev.properties` and `src/main/java`. Its tasks are `runBackend`, `backendPackage`, `cn1Update` and the standard `test`. `backendPackage` writes the binary to `build/`: +A backend-only project has the same bootstrap with the server at the root: `application.properties`, `application-dev.properties` and `src/main/java`. Its tasks are `runBackend`, `backendPackage`, `backendTest`, `cn1Update` and the standard `test`, which runs the backend's tests on this JVM; <> covers writing them. `backendPackage` writes the binary to `build/`: [source,bash] ---- diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 631206b4dd2..28cc64f24f3 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -125,6 +125,8 @@ include::Backend-Web.asciidoc[] include::Backend-Beans.asciidoc[] +include::Backend-Testing.asciidoc[] + include::Backend-Data.asciidoc[] include::Backend-Sessions.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index b55c63d062e..fe951653e5d 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -785,3 +785,5 @@ libimobiledevice # The GNOME desktop's design language, which names the Linux desktop native # theme (Native-Themes.asciidoc). Adwaita +Mockito +Toolchain diff --git a/docs/website/content/blog/websocket-server-tests-our-apps.md b/docs/website/content/blog/websocket-server-tests-our-apps.md index 4beb1309e3d..29c0479358f 100644 --- a/docs/website/content/blog/websocket-server-tests-our-apps.md +++ b/docs/website/content/blog/websocket-server-tests-our-apps.md @@ -70,9 +70,9 @@ flowchart TD The metadata identifies the capture. The binary message carries the image. An acknowledgment tells the runner when it can proceed. That exercises text and binary messages, connection lifetime, and the application-level ordering we actually depend on. -The current receiver is [Cn1ssScreenshotServer in the backend demo tree](https://github.com/codenameone/CodenameOne/blob/master/vm/backend/demo/cn1ss/com/demo/Cn1ssScreenshotServer.java). [The runner helper](https://github.com/codenameone/CodenameOne/blob/master/scripts/lib/cn1ss.sh) selects the transport implementation. We retain the earlier standalone server for the native Windows screenshot path, which doesn't use this backend arm. +The current receiver is [Cn1ssEndpoint in the sample application's backend](https://github.com/codenameone/CodenameOne/blob/master/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpoint.java). The same backend serves the REST endpoints the app's networking tests call, and [the runner helper](https://github.com/codenameone/CodenameOne/blob/master/scripts/lib/cn1ss.sh) starts it for every screenshot leg, including the native Windows and Linux ones. -That scope matters. We are using the backend in our primary screenshot infrastructure; we haven't replaced every host-side test tool. The benefit is still direct: fixes to the public WebSocket implementation are exercised by a workload producing artifacts we inspect. +The benefit is direct: fixes to the public WebSocket implementation are exercised by a workload producing artifacts we inspect. ## A connection holds resources longer than a request diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index e4388d3a898..250eaf5e509 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -614,6 +614,18 @@ "ClipboardRoundTripTest" ] }, + { + "id": "backend-networking", + "category": "Application runtime", + "name": "Networking against a Codename One backend", + "description": "Drives the CI's own backend with the app's networking APIs: Rest with every verb, headers, JSON and authentication, the generated typed REST client, ConnectionRequest with error statuses, redirects, cookies, sessions and timeouts, a multipart upload, a download to a file and a gzipped answer.", + "tests": [ + "BackendRestTest", + "BackendTypedClientTest", + "BackendConnectionRequestTest", + "BackendTransferTest" + ] + }, { "id": "contact-picker", "category": "Device APIs", diff --git a/maven/backend-test/pom.xml b/maven/backend-test/pom.xml new file mode 100644 index 00000000000..7d8ec8dc95e --- /dev/null +++ b/maven/backend-test/pom.xml @@ -0,0 +1,196 @@ + + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + + codenameone-backend-test + jar + Codename One Backend Test Support + + + UTF-8 + 1.8 + 1.8 + ${project.basedir}/../../vm/backend/test + + + + + com.codenameone + codenameone-backend + ${project.version} + + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + + + + + ${backend.test.dir}/src + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-javase-half + generate-sources + + add-source + + + + ${backend.test.dir}/javase + + + + + attach-parparvm-sources + package + + attach-artifact + + + + + ${project.build.directory}/${project.build.finalName}-parparvm-sources.jar + jar + parparvm-sources + + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + + + parparvm-sources-jar + package + + run + + + + + + + + + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.7.3.4 + + Max + Low + true + true + ${project.build.directory} + ${project.basedir}/spotbugs-exclude.xml + + + + spotbugs + verify + + spotbugs + check + + + + + + org.apache.maven.plugins + maven-pmd-plugin + 3.21.0 + + false + false + 1.8 + + ${project.basedir}/../core-unittests/pmd.xml + + + + + pmd + verify + + pmd + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.0 + + ${project.basedir}/../core-unittests/checkstyle.xml + ${project.build.sourceEncoding} + false + false + false + false + ${project.build.directory}/checkstyle-result.xml + xml + + ${backend.test.dir}/src + ${backend.test.dir}/javase + ${backend.test.dir}/parparvm + ${backend.test.dir}/junit-shim + + + + + validate + verify + + checkstyle + + + + + + + diff --git a/maven/backend-test/spotbugs-exclude.xml b/maven/backend-test/spotbugs-exclude.xml new file mode 100644 index 00000000000..bdd4cc0784c --- /dev/null +++ b/maven/backend-test/spotbugs-exclude.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/maven/backend/spotbugs-exclude.xml b/maven/backend/spotbugs-exclude.xml index da673069930..3929232aa90 100644 --- a/maven/backend/spotbugs-exclude.xml +++ b/maven/backend/spotbugs-exclude.xml @@ -132,8 +132,32 @@ image), the check is skipped. --> - + + + + + + + + + + + + + + + + + + + diff --git a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java index 74a17a5a5fd..c02de419a5d 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ApplicationRuntimeTest.java @@ -22,8 +22,17 @@ */ package com.codename1.backend; -import com.codename1.backend.mcp.McpServer; -import com.codename1.backend.mcp.McpTool; +import com.codename1.impl.backend.AsyncTask; +import com.codename1.impl.backend.BackendApplication; +import com.codename1.impl.backend.CronSchedule; +import com.codename1.impl.backend.ManagedBean; +import com.codename1.impl.backend.Management; +import com.codename1.impl.backend.MetricsAccess; +import com.codename1.impl.backend.Scheduler; +import com.codename1.impl.backend.WiringEnvironment; + +import com.codename1.impl.backend.mcp.McpServer; +import com.codename1.impl.backend.mcp.McpTool; import com.codename1.backend.metrics.Counter; import com.codename1.backend.metrics.Histogram; import com.codename1.backend.metrics.Metrics; @@ -221,11 +230,11 @@ public double read() { void enumArgumentsByName() { Map args = new LinkedHashMap(); args.put("state", "PENDING"); - assertEquals(Labelled.PENDING, com.codename1.backend.mcp.McpArgs.enumValue( + assertEquals(Labelled.PENDING, com.codename1.impl.backend.mcp.McpArgs.enumValue( Labelled.values(), args, "state", true)); args.put("state", "Waiting for payment"); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.enumValue(Labelled.values(), args, + () -> com.codename1.impl.backend.mcp.McpArgs.enumValue(Labelled.values(), args, "state", true)); } @@ -277,14 +286,14 @@ public double read() { return 1; } }; - Metrics.addSource("test.shared.replace", "", "", old); + MetricsAccess.get().addSource("test.shared.replace", "", "", old); com.codename1.backend.metrics.Gauge replacement = Metrics.gauge("test.shared.replace", "", "", new com.codename1.backend.metrics.Gauge.Source() { public double read() { return 2; } }); - Metrics.removeSource("test.shared.replace", old); // the old server stops + MetricsAccess.get().removeSource("test.shared.replace", old); // the old server stops assertTrue(Metrics.get("test.shared.replace") == replacement, "removing the replaced source deleted the application's gauge"); } @@ -311,10 +320,10 @@ void floatArgumentRange() { args.put("f", new Double(1e100)); args.put("ok", new Double(1.5)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.floatValue(args, "f", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.floatValue(args, "f", true)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.floatObject(args, "f", true)); - assertEquals(1.5f, com.codename1.backend.mcp.McpArgs.floatValue(args, "ok", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.floatObject(args, "f", true)); + assertEquals(1.5f, com.codename1.impl.backend.mcp.McpArgs.floatValue(args, "ok", true)); } @Test @@ -330,12 +339,12 @@ void stringArgumentRefusesStructures() { args.put("number", Long.valueOf(42)); args.put("text", "42"); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.string(args, "list", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.string(args, "list", true)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.string(args, "object", true)); - assertEquals("42", com.codename1.backend.mcp.McpArgs.string(args, "number", true)); - assertEquals("42", com.codename1.backend.mcp.McpArgs.string(args, "text", true)); - assertNull(com.codename1.backend.mcp.McpArgs.string(args, "absent", false)); + () -> com.codename1.impl.backend.mcp.McpArgs.string(args, "object", true)); + assertEquals("42", com.codename1.impl.backend.mcp.McpArgs.string(args, "number", true)); + assertEquals("42", com.codename1.impl.backend.mcp.McpArgs.string(args, "text", true)); + assertNull(com.codename1.impl.backend.mcp.McpArgs.string(args, "absent", false)); } @Test @@ -366,9 +375,9 @@ public double read() { } }; assertThrows(IllegalArgumentException.class, - () -> Metrics.addSource("test_shared_x", "", "", src)); + () -> MetricsAccess.get().addSource("test_shared_x", "", "", src)); assertThrows(IllegalArgumentException.class, - () -> Metrics.addSource("test_shared_x", "", "", src), + () -> MetricsAccess.get().addSource("test_shared_x", "", "", src), "a retry found the failed entry and registered no gauge"); } @@ -382,7 +391,7 @@ void errorDuringCreateCleansUp() throws Exception { assertThrows(NoClassDefFoundError.class, () -> Backend.builder( Config.of(settings, "test")).quiet() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { throw new NoClassDefFoundError("com/example/Missing"); } @@ -911,7 +920,7 @@ public int size() { Backend backend = Backend.builder(Config.of(settings, "test")).quiet() .sessionStore(failing) .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) { request.getSession(true).setAttribute("seen", "yes"); @@ -961,7 +970,7 @@ void awaitTerminationWaitsForTheDeferredTeardown() throws Exception { settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); running[0] = Backend.builder(Config.of(settings, "test")).quiet() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) { running[0].stop(); @@ -1048,7 +1057,7 @@ void failedStartDrainsItsTasks() throws Exception { assertThrows(IllegalStateException.class, () -> Backend.builder( Config.of(settings, "test")).quiet().shutdownTimeoutMillis(5000) .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { // A @PostConstruct that starts work, then a later bean // that refuses its configuration. Tasks.executor("boot", Tasks.PLATFORM).execute(new Runnable() { @@ -1226,7 +1235,7 @@ public Object invoke(int index, Map arguments) { settings.setProperty(Management.TOKEN, "t0k"); Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { environment.registerManaged(cache); return new HttpServer.Handler[0]; } @@ -1591,22 +1600,22 @@ void narrowArgumentsAreRangeChecked() { args.put("b", new Long(-129)); args.put("ok", new Long(-128)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.shortValue(args, "s", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.shortValue(args, "s", true)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.shortObject(args, "s", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.shortObject(args, "s", true)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.byteValue(args, "b", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.byteValue(args, "b", true)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.byteObject(args, "b", true)); - assertEquals(-128, com.codename1.backend.mcp.McpArgs.byteValue(args, "ok", true)); - assertEquals(-128, com.codename1.backend.mcp.McpArgs.shortValue(args, "ok", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.byteObject(args, "b", true)); + assertEquals(-128, com.codename1.impl.backend.mcp.McpArgs.byteValue(args, "ok", true)); + assertEquals(-128, com.codename1.impl.backend.mcp.McpArgs.shortValue(args, "ok", true)); // A JSON number too big for a long must not saturate to Long.MAX_VALUE. args.put("huge", new Double(1e20)); args.put("big", new Double(9.007199254740992E15)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.longValue(args, "huge", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.longValue(args, "huge", true)); assertEquals(9007199254740992L, - com.codename1.backend.mcp.McpArgs.longValue(args, "big", true)); + com.codename1.impl.backend.mcp.McpArgs.longValue(args, "big", true)); } // ---------------------------------------------------------------- metrics @@ -1758,86 +1767,6 @@ void aLostRotationIsNotAnnounced(@org.junit.jupiter.api.io.TempDir java.io.File } } - @Test - @DisplayName("a claim written after its lease ran out does not run the job") - void aClaimThatLandsExpiredIsNotALease(@org.junit.jupiter.api.io.TempDir java.io.File dir) - throws Exception { - final String path = new java.io.File(dir, "late.db").getAbsolutePath(); - DataSource pool = DataSource.open(path, 2, 5000, 10000); - try { - Scheduler scheduler = new Scheduler(pool); - Runnable nothing = new Runnable() { - public void run() { - } - }; - Scheduler.Job job = new Scheduler.Job("late", Scheduler.FIXED_DELAY, null, 1000, 0, - null, Tasks.PLATFORM, "late", 300, nothing); - // Creates the table and the row, then frees it. - scheduler.release(job, scheduler.claim(job)); - // Another writer holds the database for longer than the lease, so the - // claim's write waits past it, as a replica waiting on a lock would. - final java.util.concurrent.CountDownLatch holding = - new java.util.concurrent.CountDownLatch(1); - Thread blocker = new Thread() { - public void run() { - try { - Database db = Database.open(path); - db.execute("BEGIN IMMEDIATE", null); - holding.countDown(); - Thread.sleep(700); - db.execute("COMMIT", null); - db.close(); - } catch (Exception err) { - holding.countDown(); - } - } - }; - blocker.start(); - assertTrue(holding.await(5, TimeUnit.SECONDS)); - long started = System.currentTimeMillis(); - assertNull(scheduler.claim(job), - "a claim written after its lease had run out was returned as a lease"); - assertTrue(System.currentTimeMillis() - started >= 300, - "the claim did not wait on the held lock, so this proved nothing"); - blocker.join(5000); - } finally { - pool.close(); - } - } - - @Test - @DisplayName("an expired run releases only its own lease, not a later run's") - void anExpiredRunDoesNotReleaseItsSuccessor(@org.junit.jupiter.api.io.TempDir java.io.File dir) - throws Exception { - DataSource pool = DataSource.open(new java.io.File(dir, "l.db").getAbsolutePath(), - 2, 5000, 10000); - try { - Scheduler scheduler = new Scheduler(pool); - Runnable nothing = new Runnable() { - public void run() { - } - }; - // Long enough to still be live once the claim is written -- a claim - // that lands already expired is refused -- and short enough to run out. - Scheduler.Job slow = new Scheduler.Job("slow", Scheduler.FIXED_DELAY, null, 1000, 0, - null, Tasks.PLATFORM, "shared", 200, nothing); - Scheduler.Job next = new Scheduler.Job("next", Scheduler.FIXED_DELAY, null, 1000, 0, - null, Tasks.PLATFORM, "shared", 60000, nothing); - String expired = scheduler.claim(slow); - assertNotNull(expired); - Thread.sleep(260); // the lease runs out - String current = scheduler.claim(next); - assertNotNull(current, "an expired lease was not taken over"); - scheduler.release(slow, expired); // the overrun finally ends - assertNull(scheduler.claim(slow), - "the overrun released the lease a later run now holds"); - scheduler.release(next, current); - assertNotNull(scheduler.claim(slow), "releasing its own lease freed nothing"); - } finally { - pool.close(); - } - } - @Test @DisplayName("a colon is legal in a Prometheus metric name but not in a label name") void prometheusLabelNamesHaveNoColon() { @@ -1881,9 +1810,9 @@ void theNegativeLongBoundaryIsChecked() { args.put("rounded", Double.valueOf(-9223372036854775809.0)); args.put("exact", Long.valueOf(Long.MIN_VALUE)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.longValue(args, "rounded", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.longValue(args, "rounded", true)); assertEquals(Long.MIN_VALUE, - com.codename1.backend.mcp.McpArgs.longValue(args, "exact", true)); + com.codename1.impl.backend.mcp.McpArgs.longValue(args, "exact", true)); } @Test @@ -1970,10 +1899,10 @@ void nonFiniteNumberStringsAreRefused() { args.put("inf", "-Infinity"); args.put("ok", "2.5"); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.doubleValue(args, "nan", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.doubleValue(args, "nan", true)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.doubleValue(args, "inf", true)); - assertEquals(2.5, com.codename1.backend.mcp.McpArgs.doubleValue(args, "ok", true), 0.0); + () -> com.codename1.impl.backend.mcp.McpArgs.doubleValue(args, "inf", true)); + assertEquals(2.5, com.codename1.impl.backend.mcp.McpArgs.doubleValue(args, "ok", true), 0.0); } @Test @@ -2267,8 +2196,8 @@ void floatUnderflowIsRefused() { args.put("tiny", Double.valueOf(1e-100)); args.put("zero", Double.valueOf(0)); assertThrows(IllegalArgumentException.class, - () -> com.codename1.backend.mcp.McpArgs.floatValue(args, "tiny", true)); - assertEquals(0f, com.codename1.backend.mcp.McpArgs.floatValue(args, "zero", true)); + () -> com.codename1.impl.backend.mcp.McpArgs.floatValue(args, "tiny", true)); + assertEquals(0f, com.codename1.impl.backend.mcp.McpArgs.floatValue(args, "zero", true)); } @Test @@ -2377,7 +2306,7 @@ void loopbackIsCheckedStrictly() { @DisplayName("a JSON-RPC id that is an object, array or boolean is refused before the method runs") void unsupportedIdTypesAreRefused() throws Exception { final int[] calls = {0}; - com.codename1.backend.mcp.McpTool tool = new com.codename1.backend.mcp.McpTool() { + com.codename1.impl.backend.mcp.McpTool tool = new com.codename1.impl.backend.mcp.McpTool() { public String name() { return "touch"; } @@ -2421,7 +2350,7 @@ void backendCallUsesTheBoundAddress() throws Exception { try { backend = Backend.builder(Config.of(settings, "dev")).quiet() .application(new EmptyApplication()) - .mcp(new com.codename1.backend.mcp.DevTools()) + .mcp(new com.codename1.impl.backend.mcp.DevTools()) .handler(new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) throws Exception { @@ -2460,7 +2389,7 @@ void requestBeansBuiltDuringDestroyAreDrained() throws Exception { settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); Backend backend = Backend.builder(Config.of(settings, "test")).quiet() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) { request.scopedBeans(1)[0] = "a"; @@ -2506,7 +2435,7 @@ void requestBeansEndWhenSerialisationFails() throws Exception { settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); Backend backend = Backend.builder(Config.of(settings, "test")).quiet() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) { request.scopedBeans(1)[0] = "bean"; @@ -2540,7 +2469,7 @@ void deferredJsonIsWrittenBeforeRequestBeansEnd() throws Exception { settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); Backend backend = Backend.builder(Config.of(settings, "test")).quiet() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) { List owned = new ArrayList(); @@ -2572,7 +2501,7 @@ void concurrentRequestKeepsTheLoginsRotation() throws Exception { settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); Backend backend = Backend.builder(Config.of(settings, "test")).quiet() .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) throws Exception { @@ -2674,7 +2603,7 @@ public int size() { Backend backend = Backend.builder(Config.of(settings, "test")).quiet() .sessionStore(store) .application(new EmptyApplication() { - public HttpServer.Handler[] create(Backend.Environment environment) { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[] {new HttpServer.Handler() { public HttpServer.Response handle(HttpServer.Request request) throws Exception { @@ -2776,7 +2705,7 @@ public double read() { return 1; } }; - Metrics.addSource("test.drain.gauge", "", "", slow); + MetricsAccess.get().addSource("test.drain.gauge", "", "", slow); Thread reader = new Thread(new Runnable() { public void run() { Metrics.snapshot(); @@ -2787,7 +2716,7 @@ public void run() { final boolean[] removed = {false}; Thread remover = new Thread(new Runnable() { public void run() { - Metrics.removeSource("test.drain.gauge", slow); + MetricsAccess.get().removeSource("test.drain.gauge", slow); removed[0] = true; } }); @@ -2823,8 +2752,8 @@ public double read() { return 2; } }; - Metrics.addSource("test.stuck.gauge", "", "", stuck); - Metrics.addSource("test.quick.gauge", "", "", quick); + MetricsAccess.get().addSource("test.stuck.gauge", "", "", stuck); + MetricsAccess.get().addSource("test.quick.gauge", "", "", quick); Thread reader = new Thread(new Runnable() { public void run() { Metrics.snapshot(); @@ -2834,13 +2763,13 @@ public void run() { try { assertTrue(reading.await(5, TimeUnit.SECONDS)); long start = System.currentTimeMillis(); - Metrics.removeSource("test.quick.gauge", quick); + MetricsAccess.get().removeSource("test.quick.gauge", quick); assertTrue(System.currentTimeMillis() - start < 1000, "another gauge's stuck read delayed this removal"); } finally { release.countDown(); reader.join(5000); - Metrics.removeSource("test.stuck.gauge", stuck); + MetricsAccess.get().removeSource("test.stuck.gauge", stuck); } } @@ -2914,7 +2843,7 @@ void nonObjectParamsAreRefused() throws Exception { @DisplayName("tools/call with arguments that are not an object is refused, and the tool never runs") void nonObjectArgumentsAreRefused() throws Exception { final int[] calls = {0}; - com.codename1.backend.mcp.McpTool tool = new com.codename1.backend.mcp.McpTool() { + com.codename1.impl.backend.mcp.McpTool tool = new com.codename1.impl.backend.mcp.McpTool() { public String name() { return "touch"; } @@ -2963,7 +2892,7 @@ void configuredPathsAreCanonical() throws Exception { } private static Backend startAnswering(int port, final String text, - com.codename1.backend.mcp.DevTools tools) throws Exception { + com.codename1.impl.backend.mcp.DevTools tools) throws Exception { Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); return Backend.builder(Config.of(settings, "dev")).quiet() @@ -3238,8 +3167,8 @@ public void metrics(Map out) { // ----------------------------------------------------------------- helpers /** An application with no beans, for tests that only need the hooks. */ - static class EmptyApplication implements Backend.Application { - public HttpServer.Handler[] create(Backend.Environment environment) { + static class EmptyApplication implements BackendApplication { + public HttpServer.Handler[] create(WiringEnvironment environment) { return new HttpServer.Handler[0]; } diff --git a/maven/backend/src/test/java/com/codename1/backend/BackendTest.java b/maven/backend/src/test/java/com/codename1/backend/BackendTest.java index 10aa731254d..eba80d387a2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/BackendTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/BackendTest.java @@ -78,6 +78,43 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { } } + @Test + @DisplayName("a compiled-in ${DATABASE_URL} nobody set does not stop a server that needs no database") + void anUnsetDatabaseReferenceIsNotADatabase() throws Exception { + // The archetype's committed application.properties says + // cn1.datasource.url=${DATABASE_URL}, and the build compiles it into the + // binary. A server with no entity and no handler that needs a database + // must still start where that variable is unset; one that needs a + // database must still refuse, naming it. + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Config config = Config.of(settings, "production").withCompiledDefaults(new String[] { + Config.DATASOURCE_URL, "${CN1_TEST_NEVER_SET_DATABASE_URL}"}); + Backend backend = Backend.builder(config) + .quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + return HttpServer.Response.text(200, "ok"); + } + }) + .start(); + try { + assertEquals("ok", get(port, "/anything")); + assertNull(backend.getDataSource(), "no database was asked for"); + } finally { + backend.stop(); + } + Properties needy = new Properties(); + needy.setProperty(Config.SERVER_PORT, String.valueOf(freePort())); + Config needs = Config.of(needy, "production").withCompiledDefaults(new String[] { + Config.DATASOURCE_URL, "${CN1_TEST_NEVER_SET_DATABASE_URL}"}); + java.io.IOException refused = assertThrows(java.io.IOException.class, + () -> Backend.builder(needs).quiet().requiresDataSource().start()); + assertTrue(refused.getMessage().contains("CN1_TEST_NEVER_SET_DATABASE_URL"), + refused.getMessage()); + } + @Test @DisplayName("handlers are tried in order and static files come last") void staticFilesCannotShadowARoute(@TempDir File dir) throws Exception { diff --git a/maven/backend/src/test/java/com/codename1/backend/ConfigTest.java b/maven/backend/src/test/java/com/codename1/backend/ConfigTest.java index de7670bf2a6..5048345c8d2 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ConfigTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ConfigTest.java @@ -126,6 +126,63 @@ void systemPropertyWins(@TempDir File dir) throws Exception { assertEquals(9999, Config.load(dir.getAbsolutePath()).getInt(Config.SERVER_PORT, 0)); } + @Test + @DisplayName("resolves: absent and unset-without-fallback are unset, a fallback resolves") + void resolvesTellsAskedForFromUnresolvable() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.test.a", "${CN1_TEST_NEVER_SET_VAR}"); + p.setProperty("cn1.test.b", "${CN1_TEST_NEVER_SET_VAR:fallback}"); + p.setProperty("cn1.test.c", "plain"); + Config config = Config.of(p, "production"); + assertFalse(config.resolves("cn1.test.absent")); + assertFalse(config.resolves("cn1.test.a")); + assertTrue(config.resolves("cn1.test.b")); + assertTrue(config.resolves("cn1.test.c")); + // get() still fails loudly for code that reads the value. + assertThrows(IOException.class, () -> config.get("cn1.test.a")); + // A malformed reference is not "unset": it still fails. + p.setProperty("cn1.test.d", "${UNCLOSED"); + assertThrows(IOException.class, () -> config.resolves("cn1.test.d")); + } + + @Test + @DisplayName("a test's own settings win over the process, and only those") + void testSettingsWinOverTheProcess() throws Exception { + // What @BackendTest builds: a shell that exports PORT or DATABASE_URL + // must not move a test off the free port or the in-memory database it + // asked for. + System.setProperty(Config.SERVER_PORT, "9999"); + System.setProperty(Config.DATASOURCE_URL, "postgres://prod/db"); + Properties mine = new Properties(); + mine.setProperty(Config.SERVER_PORT, "0"); + Config config = Config.overriding(mine, "test") + .withCompiledDefaults(new String[] {"cn1.test.value", "compiled"}); + assertEquals(0, config.getInt(Config.SERVER_PORT, 8080)); + // A key the test does not name falls through to the usual layers. + assertEquals("postgres://prod/db", config.get(Config.DATASOURCE_URL)); + assertEquals("compiled", config.get("cn1.test.value")); + assertTrue(config.keys().contains(Config.SERVER_PORT), String.valueOf(config.keys())); + assertTrue(config.isDevelopmentProfile()); + } + + @Test + @DisplayName("a compiled-in cn1.profile applies when nothing above it names one") + void aCompiledProfileApplies(@TempDir File dir) throws Exception { + // A packaged server with no file beside it: the committed + // application.properties, compiled in, chooses the profile. + write(dir, "application-staging.properties", "cn1.stage=yes\n"); + Config config = Config.load(dir.getAbsolutePath()) + .withCompiledDefaults(new String[] {Config.PROFILE, "staging"}); + assertEquals("staging", config.getProfile()); + assertEquals("yes", config.get("cn1.stage"), "the compiled profile's file was not read"); + // A base file on disk that names a profile still wins over the compiled one. + File other = new File(dir, "named"); + other.mkdirs(); + write(other, "application.properties", "cn1.profile=dev\n"); + assertEquals("dev", Config.load(other.getAbsolutePath()) + .withCompiledDefaults(new String[] {Config.PROFILE, "staging"}).getProfile()); + } + @Test @DisplayName("no files at all is a configuration, not a failure") void missingFilesAreNotAnError(@TempDir File dir) throws Exception { diff --git a/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java b/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java new file mode 100644 index 00000000000..bc3ed01ae59 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java @@ -0,0 +1,610 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.util.List; +import java.util.Properties; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Request bodies that are not UTF-8 text -- uploads, multipart forms, gzip -- +/// and the two response policies a browser or a mobile client relies on: CORS +/// and compression. Real sockets, so the HTTP/1.1 parser is what decides. +class HttpBodiesTest { + private Backend backend; + private int port; + + @AfterEach + void stop() { + if (backend != null) { + backend.stop(); + } + } + + /// One Response for every request, as a handler with a constant answer returns. + private static final HttpServer.Response SHARED; + + static { + StringBuilder sb = new StringBuilder(); + for (int iter = 0 ; iter < 500 ; iter++) { + sb.append("{\"n\":").append(iter).append("},"); + } + SHARED = HttpServer.Response.json(200, "[" + sb + "{}]"); + } + + /// Echoes what the request arrived as: the content type, the body's length + /// and form, its parts and parameters. + private void start(Properties extra) throws Exception { + port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + if (extra != null) { + settings.putAll(extra); + } + backend = Backend.builder(Config.of(settings, "test")).quiet() + .handler(new HttpServer.Handler() { + public HttpServer.Response handle(HttpServer.Request request) throws Exception { + String path = request.getTarget(); + if ("OPTIONS".equals(request.getMethod())) { + // Declined, as a generated router declines a method it + // has no route for, so the CORS policy answers it. + return null; + } + if (path.startsWith("/bytes")) { + byte[] body = request.getBodyBytes(); + return new HttpServer.Response(200, "application/octet-stream", + body == null ? new byte[0] : body); + } + if (path.startsWith("/text")) { + return HttpServer.Response.text(200, String.valueOf(request.getBody())); + } + if (path.startsWith("/parts")) { + StringBuilder sb = new StringBuilder(); + List parts = request.getParts(); + for (int iter = 0 ; iter < parts.size() ; iter++) { + HttpServer.Part p = (HttpServer.Part) parts.get(iter); + sb.append(p.getName()).append('|').append(p.getFilename()) + .append('|').append(p.getContentType()).append('|') + .append(p.getSize()).append(';'); + } + return HttpServer.Response.text(200, sb.toString()); + } + if (path.startsWith("/param")) { + return HttpServer.Response.text(200, request.param("a") + "," + + request.param("b")); + } + if (path.startsWith("/shared")) { + return SHARED; + } + if (path.startsWith("/etag")) { + StringBuilder sb = new StringBuilder(); + while (sb.length() < 5000) { + sb.append("{\"n\":1},"); + } + return HttpServer.Response.json(200, "[" + sb + "{}]") + .header("ETag", path.endsWith("weak") ? "W/\"v1\"" : "\"v1\""); + } + if (path.startsWith("/range")) { + // Bytes 0-4999 of a larger JSON document, as a range answer. + StringBuilder sb = new StringBuilder(); + while (sb.length() < 5000) { + sb.append('x'); + } + return HttpServer.Response.json(206, sb.toString()) + .header("Content-Range", "bytes 0-4999/20000"); + } + if (path.startsWith("/big")) { + StringBuilder sb = new StringBuilder(); + for (int iter = 0 ; iter < 500 ; iter++) { + sb.append("{\"n\":").append(iter).append("},"); + } + return HttpServer.Response.json(200, "[" + sb + "{}]"); + } + return null; + } + }) + .start(); + } + + @Test + @DisplayName("a binary upload arrives byte for byte; it used to be refused as bad UTF-8") + void aBinaryBodyIsKeptAsBytes() throws Exception { + start(null); + byte[] png = new byte[256]; + for (int iter = 0 ; iter < png.length ; iter++) { + png[iter] = (byte) iter; + } + HttpURLConnection c = post("/bytes", "image/png", png, null); + assertEquals(200, c.getResponseCode()); + assertArrayEquals(png, read(c.getInputStream())); + } + + @Test + @DisplayName("a text body that is not UTF-8 is still a 400") + void malformedTextIsStillRefused() throws Exception { + start(null); + HttpURLConnection c = post("/text", "text/plain", new byte[] {(byte) 0xC3, (byte) 0x28}, + null); + assertEquals(400, c.getResponseCode()); + } + + @Test + @DisplayName("a gzip request body is inflated before the handler sees it") + void aGzipBodyIsDecoded() throws Exception { + start(null); + HttpURLConnection c = post("/text", "application/json", gzip("{\"hello\":\"world\"}"), + "gzip"); + assertEquals(200, c.getResponseCode()); + assertEquals("{\"hello\":\"world\"}", new String(read(c.getInputStream()), "UTF-8")); + + c = post("/text", "application/json", "not gzip".getBytes("UTF-8"), "gzip"); + assertEquals(400, c.getResponseCode(), "bytes that are not gzip were accepted"); + c = post("/text", "application/json", "x".getBytes("UTF-8"), "br"); + assertEquals(415, c.getResponseCode(), "a coding the server cannot decode was accepted"); + } + + @Test + @DisplayName("a multipart form splits into its fields and files") + void multipartPartsAndFields() throws Exception { + start(null); + String boundary = "----cn1boundary"; + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write(("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"a\"\r\n\r\n" + + "first\r\n" + + "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"x.bin\"\r\n" + + "Content-Type: application/octet-stream\r\n\r\n").getBytes("UTF-8")); + body.write(new byte[] {0, (byte) 0xFF, 13, 10, 45, 45}); + body.write(("\r\n--" + boundary + "--\r\n").getBytes("UTF-8")); + String type = "multipart/form-data; boundary=" + boundary; + + HttpURLConnection c = post("/parts", type, body.toByteArray(), null); + assertEquals(200, c.getResponseCode()); + assertEquals("a|null|null|5;file|x.bin|application/octet-stream|6;", + new String(read(c.getInputStream()), "UTF-8")); + + // A text part is a @RequestParam too; a file part is not. + c = post("/param", type, body.toByteArray(), null); + assertEquals("first,null", new String(read(c.getInputStream()), "UTF-8")); + } + + @Test + @DisplayName("a urlencoded form is read by param(), after the query string") + void formFieldsAreParameters() throws Exception { + start(null); + HttpURLConnection c = post("/param?b=fromQuery", "application/x-www-form-urlencoded", + "a=one+two%21&b=fromForm".getBytes("UTF-8"), null); + assertEquals("one two!,fromQuery", new String(read(c.getInputStream()), "UTF-8")); + } + + @Test + @DisplayName("CORS answers an allowed origin's preflight and tags its responses") + void corsPreflightAndResponses() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.cors.allowedOrigins", "http://app.example"); + p.setProperty("cn1.cors.exposedHeaders", "X-Total"); + start(p); + // A raw socket: HttpURLConnection drops Origin and the + // Access-Control-Request-* headers as restricted, so through it a + // preflight never arrives as one. + String preflight = raw("OPTIONS /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Access-Control-Request-Method: POST\r\n" + + "Access-Control-Request-Headers: content-type\r\nConnection: close\r\n\r\n"); + assertTrue(preflight.startsWith("HTTP/1.1 204"), preflight); + assertTrue(preflight.contains("Access-Control-Allow-Origin: http://app.example"), preflight); + assertTrue(preflight.contains("Access-Control-Allow-Headers: content-type"), preflight); + + String refused = raw("OPTIONS /text HTTP/1.1\r\nHost: x\r\nOrigin: http://evil.example\r\n" + + "Access-Control-Request-Method: POST\r\nConnection: close\r\n\r\n"); + assertTrue(refused.startsWith("HTTP/1.1 403"), refused); + + String allowed = raw("POST /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Content-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(allowed.startsWith("HTTP/1.1 200"), allowed); + assertTrue(allowed.contains("Access-Control-Allow-Origin: http://app.example"), allowed); + assertTrue(allowed.contains("Access-Control-Expose-Headers: X-Total"), allowed); + assertTrue(allowed.contains("Vary: Origin"), allowed); + + // Refused before any handler runs, as Spring's CorsProcessor refuses it: a + // simple request needs no preflight, so the handler used to run anyway. + String other = raw("POST /text HTTP/1.1\r\nHost: x\r\nOrigin: http://evil.example\r\n" + + "Content-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(other.startsWith("HTTP/1.1 403"), other); + assertTrue(!other.contains("Access-Control-Allow-Origin"), + "an origin the policy does not list was allowed to read: " + other); + // The answer still depends on Origin, so a shared cache must key on it -- + // for a denied origin, for none at all, and for the preflights. + assertTrue(other.contains("Vary: Origin"), "a denied origin's answer did not vary: " + other); + String none = raw("POST /text HTTP/1.1\r\nHost: x\r\nContent-Type: text/plain\r\n" + + "Content-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(none.contains("Vary: Origin"), "an answer without Origin did not vary: " + none); + assertTrue(preflight.contains("Vary: Origin"), preflight); + assertTrue(refused.contains("Vary: Origin"), refused); + } + + @Test + @DisplayName("a simple cross-origin request with a method the policy omits is refused") + void corsEnforcesAllowedMethodsOnSimpleRequests() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.cors.allowedOrigins", "http://app.example"); + p.setProperty("cn1.cors.allowedMethods", "GET"); + start(p); + String post = raw("POST /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Content-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(post.startsWith("HTTP/1.1 403"), "a POST passed a GET-only policy: " + post); + String preflight = raw("OPTIONS /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Access-Control-Request-Method: DELETE\r\nConnection: close\r\n\r\n"); + assertTrue(preflight.startsWith("HTTP/1.1 403"), preflight); + String get = raw("GET /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Connection: close\r\n\r\n"); + assertTrue(get.startsWith("HTTP/1.1 200"), get); + // A same-origin request carries Origin too, and is not a CORS request. + String same = raw("POST /text HTTP/1.1\r\nHost: 127.0.0.1:" + port + "\r\nOrigin: http://127.0.0.1:" + + port + "\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(same.startsWith("HTTP/1.1 200"), "a same-origin POST was refused: " + same); + // The same authority under another scheme is another origin: this server + // is plain HTTP, so an https page calling it is a CORS request. + String otherScheme = raw("POST /text HTTP/1.1\r\nHost: 127.0.0.1:" + port + "\r\nOrigin: https://127.0.0.1:" + + port + "\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(otherScheme.startsWith("HTTP/1.1 403"), "another scheme passed as same-origin: " + otherScheme); + // Behind a TLS terminating proxy that says so, the https page is the server itself. + String proxied = raw("POST /text HTTP/1.1\r\nHost: 127.0.0.1:" + port + "\r\nX-Forwarded-Proto: https\r\n" + + "Origin: https://127.0.0.1:" + port + "\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n" + + "Connection: close\r\n\r\nhi"); + assertTrue(proxied.startsWith("HTTP/1.1 200"), "a proxied same-origin POST was refused: " + proxied); + } + + @Test + @DisplayName("with any origin allowed and no credentials, the answer does not vary by Origin") + void anyOriginDoesNotVary() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.cors.allowedOrigins", "*"); + start(p); + String answer = raw("POST /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Content-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(answer.contains("Access-Control-Allow-Origin: *"), answer); + assertTrue(!answer.contains("Vary: Origin"), answer); + } + + @Test + @DisplayName("a multipart declaration with no boundary, or endless part headers, is refused") + void malformedMultipartIsRefused() throws Exception { + HttpServer.Request noBoundary = new HttpServer.Request("POST", "/x", "HTTP/1.1", + java.util.Collections.singletonMap("content-type", "multipart/form-data"), null); + noBoundary.setBody(null, "--x\r\n\r\nhi\r\n--x--".getBytes("UTF-8")); + assertThrows(IllegalArgumentException.class, noBoundary::getParts, + "a multipart body with no boundary read as no parts at all"); + // Short header lines, each under the limit, adding up past it. + StringBuilder headers = new StringBuilder(); + while (headers.length() < 9000) { + headers.append("X-A: b\r\n"); + } + HttpServer.Request many = new HttpServer.Request("POST", "/x", "HTTP/1.1", + java.util.Collections.singletonMap("content-type", "multipart/form-data; boundary=x"), + null); + many.setBody(null, ("--x\r\n" + headers + "\r\nhi\r\n--x--").getBytes("UTF-8")); + assertThrows(IllegalArgumentException.class, many::getParts, + "the part header block limit is per line, not for the block"); + HttpServer.Request empty = new HttpServer.Request("POST", "/x", "HTTP/1.1", + java.util.Collections.singletonMap("content-type", "multipart/form-data; boundary=x"), + null); + assertThrows(IllegalArgumentException.class, empty::getParts, + "a multipart body with no bytes, not even its closing delimiter, read as no parts"); + HttpServer.Request huge = new HttpServer.Request("POST", "/x", "HTTP/1.1", + java.util.Collections.singletonMap("content-type", "text/plain"), null); + byte[] tooLarge = new byte[8 * 1024 * 1024 + 1]; + IOException refused = assertThrows(IOException.class, + () -> HttpServer.bodyContent(huge, tooLarge, 0, tooLarge.length), + "a body past the cap was accepted outside the listener"); + assertEquals(413, HttpServer.refusalStatus(refused)); + } + + @Test + @DisplayName("a part whose bytes contain the boundary followed by more is not cut there") + void aBoundaryPrefixInsideAPartIsData() throws Exception { + HttpServer.Request r = new HttpServer.Request("POST", "/x", "HTTP/1.1", + java.util.Collections.singletonMap("content-type", "multipart/form-data; boundary=x"), + null); + r.setBody(null, ("--x\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\n" + + "before\r\n--xsuffix after\r\n--x--\r\n").getBytes("UTF-8")); + java.util.List parts = r.getParts(); + assertEquals(1, parts.size()); + assertEquals("before\r\n--xsuffix after", + new String(((HttpServer.Part) parts.get(0)).getBytes(), "UTF-8")); + } + + @Test + @DisplayName("an allowed origin with a path, query or trailing slash is refused at start") + void anOriginWithAPathIsRefused() throws Exception { + for (String bad : new String[] {"https://app.example/", "https://app.example/ui", + "https://app.example?x=1", "https://app.example#top", "app.example", "https://"}) { + Properties p = new Properties(); + p.setProperty("cn1.cors.allowedOrigins", bad); + assertThrows(IOException.class, () -> Cors.fromConfig(Config.of(p, "test")), + "accepted " + bad + ", which no browser Origin can match"); + } + Properties ok = new Properties(); + ok.setProperty("cn1.cors.allowedOrigins", "https://app.example, http://127.0.0.1:8080, *"); + assertTrue(Cors.fromConfig(Config.of(ok, "test")) != null); + } + + @Test + @DisplayName("without cn1.cors.allowedOrigins there is no CORS at all") + void corsIsOffByDefault() throws Exception { + start(null); + String answer = raw("POST /text HTTP/1.1\r\nHost: x\r\nOrigin: http://app.example\r\n" + + "Content-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nhi"); + assertTrue(answer.startsWith("HTTP/1.1 200"), answer); + assertTrue(!answer.contains("Access-Control-Allow-Origin"), answer); + } + + private String raw(String request) throws IOException { + java.net.Socket socket = new java.net.Socket("127.0.0.1", port); + try { + socket.setSoTimeout(10000); + socket.getOutputStream().write(request.getBytes("UTF-8")); + socket.getOutputStream().flush(); + return new String(read(socket.getInputStream()), "UTF-8"); + } finally { + socket.close(); + } + } + + @Test + @DisplayName("compression gzips a large JSON answer for a client that accepts it") + void compressionWhenAccepted() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.server.compression.enabled", "true"); + start(p); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/big") + .openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip"); + assertEquals(200, c.getResponseCode()); + assertEquals("gzip", c.getHeaderField("Content-Encoding")); + assertTrue(c.getHeaderField("Vary").contains("Accept-Encoding")); + String json = new String(read(new GZIPInputStream(c.getInputStream())), "UTF-8"); + assertTrue(json.startsWith("[{\"n\":0}"), json); + + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/big").openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip;q=0, identity"); + assertNull(c.getHeaderField("Content-Encoding"), "q=0 refuses gzip"); + // The identity answer was chosen by Accept-Encoding too, so it varies by it. + assertTrue(String.valueOf(c.getHeaderField("Vary")).contains("Accept-Encoding"), + "an uncompressed answer a gzip client would get compressed must vary"); + + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/text").openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip"); + assertNull(c.getHeaderField("Content-Encoding"), "a small body was compressed"); + + // An explicit gzip entry decides, wherever it sits; * counts only otherwise. + assertEquals(null, encodingFor("*;q=1, gzip;q=0"), "gzip was refused explicitly"); + assertEquals("gzip", encodingFor("*;q=0, gzip"), "gzip was accepted explicitly"); + assertEquals("gzip", encodingFor("br, *"), "* accepts gzip when it is not named"); + } + + @Test + @DisplayName("a close delimiter followed by anything but padding and CRLF is malformed") + void aCloseDelimiterMustEnd() throws Exception { + String part = "--x\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nv\r\n"; + assertThrows(IllegalArgumentException.class, + () -> Multipart.parse((part + "--x--garbage").getBytes("UTF-8"), "x")); + assertThrows(IllegalArgumentException.class, + () -> Multipart.parse("--x--garbage".getBytes("UTF-8"), "x")); + assertEquals(1, Multipart.parse((part + "--x--").getBytes("UTF-8"), "x").size()); + assertEquals(1, Multipart.parse((part + "--x-- \r\nan epilogue").getBytes("UTF-8"), "x") + .size(), "padding, CRLF and an epilogue may follow"); + } + + @Test + @DisplayName("a boundary search over a body of dashes is linear") + void aDashBoundaryOverDashesIsLinear() throws Exception { + StringBuilder boundary = new StringBuilder(); + while (boundary.length() < 69) { + boundary.append('-'); + } + boundary.append('z'); + byte[] body = new byte[8 * 1024 * 1024]; + java.util.Arrays.fill(body, (byte) '-'); + long started = System.nanoTime(); + assertThrows(IllegalArgumentException.class, + () -> Multipart.parse(body, boundary.toString())); + long ms = (System.nanoTime() - started) / 1000000L; + assertTrue(ms < 3000, "searching 8 MB of dashes took " + ms + "ms"); + } + + @Test + @DisplayName("a form value keeps a supplementary character beside + and %") + void aFormValueKeepsSupplementaryCharacters() { + String emoji = "\ud83d\ude00"; + assertEquals(emoji + " ok%", Multipart.formValue("v=" + emoji + "+ok%25", "v")); + } + + @Test + @DisplayName("a Vary the handler gave as a list is extended, not stringified") + void aListValuedVaryIsJoined() { + java.util.Map headers = new java.util.LinkedHashMap(); + headers.put("Vary", java.util.Arrays.asList("Origin", "Cookie")); + HttpServer.Response r = HttpServer.Response.empty(200, "text/plain", headers); + r.appendToken("Vary", "Accept-Encoding"); + assertEquals("Origin, Cookie, Accept-Encoding", r.headerValue("Vary")); + r.appendToken("vary", "origin"); + assertEquals("Origin, Cookie, Accept-Encoding", r.headerValue("Vary"), + "a token already listed was added again"); + } + + @Test + @DisplayName("a gzip body of several members is decoded whole, and trailing junk refused") + void everyGzipMemberIsDecoded() throws Exception { + java.io.ByteArrayOutputStream both = new java.io.ByteArrayOutputStream(); + both.write(gzip("first ".getBytes("UTF-8"))); + both.write(gzip("second".getBytes("UTF-8"))); + byte[] data = both.toByteArray(); + assertEquals("first second", new String(HttpServer.gunzip(data, 0, data.length), "UTF-8")); + java.io.ByteArrayOutputStream junk = new java.io.ByteArrayOutputStream(); + junk.write(gzip("first".getBytes("UTF-8"))); + junk.write("not gzip".getBytes("UTF-8")); + byte[] bad = junk.toByteArray(); + assertThrows(IOException.class, () -> HttpServer.gunzip(bad, 0, bad.length), + "bytes after the last member were ignored"); + } + + private static byte[] gzip(byte[] data) throws IOException { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + GZIPOutputStream gz = new GZIPOutputStream(out); + gz.write(data); + gz.close(); + return out.toByteArray(); + } + + @Test + @DisplayName("a strong ETag keeps its response uncompressed; a weak one does not") + void aStrongETagIsNotCompressed() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.server.compression.enabled", "true"); + start(p); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/etag") + .openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip"); + assertNull(c.getHeaderField("Content-Encoding"), "a strong ETag was compressed"); + assertEquals("\"v1\"", c.getHeaderField("ETag")); + read(c.getInputStream()); + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/etag-weak").openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip"); + assertEquals("gzip", c.getHeaderField("Content-Encoding")); + read(new GZIPInputStream(c.getInputStream())); + } + + @Test + @DisplayName("a range answer is not compressed, so its Content-Range stays true") + void aRangeIsNotCompressed() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.server.compression.enabled", "true"); + start(p); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/range") + .openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip"); + assertEquals(206, c.getResponseCode()); + assertNull(c.getHeaderField("Content-Encoding"), "a 206 was compressed"); + assertEquals("bytes 0-4999/20000", c.getHeaderField("Content-Range")); + assertEquals(5000, read(c.getInputStream()).length); + } + + @Test + @DisplayName("a Response the handler shares between requests is not changed by one of them") + void aSharedResponseIsCopiedBeforeThePolicies() throws Exception { + Properties p = new Properties(); + p.setProperty("cn1.server.compression.enabled", "true"); + start(p); + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/shared") + .openConnection(); + c.setRequestProperty("Accept-Encoding", "gzip"); + assertEquals("gzip", c.getHeaderField("Content-Encoding")); + read(new GZIPInputStream(c.getInputStream())); + // The next client never offered gzip. Compression used to rewrite the + // shared object, so it got gzip anyway. + c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/shared").openConnection(); + c.setRequestProperty("Accept-Encoding", "identity"); + assertNull(c.getHeaderField("Content-Encoding"), "the shared response stayed gzipped"); + String json = new String(read(c.getInputStream()), "UTF-8"); + assertTrue(json.startsWith("[{\"n\":0}"), json); + } + + private String encodingFor(String acceptEncoding) throws IOException { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/big") + .openConnection(); + c.setRequestProperty("Accept-Encoding", acceptEncoding); + String encoding = c.getHeaderField("Content-Encoding"); + read(c.getInputStream()); + return encoding; + } + + private HttpURLConnection post(String path, String type, byte[] body, String encoding) + throws IOException { + return post(path, type, body, encoding, null); + } + + private HttpURLConnection post(String path, String type, byte[] body, String encoding, + String origin) throws IOException { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", type); + if (encoding != null) { + c.setRequestProperty("Content-Encoding", encoding); + } + if (origin != null) { + c.setRequestProperty("Origin", origin); + } + OutputStream out = c.getOutputStream(); + out.write(body); + out.close(); + return c; + } + + private static byte[] gzip(String text) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + GZIPOutputStream gz = new GZIPOutputStream(out); + gz.write(text.getBytes("UTF-8")); + gz.close(); + return out.toByteArray(); + } + + private static byte[] read(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + int n; + while ((n = in.read(b)) > 0) { + out.write(b, 0, n); + } + in.close(); + return out.toByteArray(); + } + + private static int freePort() throws IOException { + ServerSocket s = new ServerSocket(0); + try { + return s.getLocalPort(); + } finally { + s.close(); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java index c217ddd5025..c482e1d64f3 100644 --- a/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/ServerEngineTransactionsTest.java @@ -22,6 +22,8 @@ */ package com.codename1.backend; +import com.codename1.impl.backend.Scheduler; + import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; diff --git a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java b/maven/backend/src/test/java/com/codename1/backend/mcp/StdioBridgeTest.java similarity index 81% rename from maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java rename to maven/backend/src/test/java/com/codename1/backend/mcp/StdioBridgeTest.java index 8b2cee495e5..061a7a05512 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/DevToolsTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/mcp/StdioBridgeTest.java @@ -25,25 +25,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -/** Which SQLite pragmas backend_sql runs without write=true. */ -class DevToolsTest { - - @Test - @DisplayName("only pragmas that read run unconfirmed, in either spelling") - void pragmaAllowList() { - assertTrue(DevTools.readOnlyPragma("PRAGMA table_info(notes)")); - assertTrue(DevTools.readOnlyPragma("pragma main.table_list")); - assertTrue(DevTools.readOnlyPragma("PRAGMA busy_timeout")); - assertFalse(DevTools.readOnlyPragma("PRAGMA busy_timeout(0)"), - "a setting given in parentheses was run unconfirmed"); - assertFalse(DevTools.readOnlyPragma("PRAGMA cache_size = 123")); - assertFalse(DevTools.readOnlyPragma("PRAGMA writable_schema(ON)")); - assertFalse(DevTools.readOnlyPragma("PRAGMA optimize")); - } - +/// The stdio bridge that relays an MCP host speaking stdio to the HTTP endpoint. +class StdioBridgeTest { @Test @DisplayName("with the backend down, the bridge answers under the request's own id") void bridgeAnswersUnderTheTopLevelId() throws Exception { @@ -136,15 +121,4 @@ void aFailedBatchAnswersEveryId() throws Exception { assertTrue(answer != null && answer.startsWith("[") && answer.contains("\"id\":1") && answer.contains("\"id\":\"two\""), String.valueOf(answer)); } - - @Test - @DisplayName("credentials in exporter headers and URL queries are masked") - void credentialsInValuesAreMasked() { - assertTrue(DevTools.secret("cn1.otel.headers", "api-key=abc123")); - assertTrue(DevTools.secret("cn1.datasource.url", - "postgres://db.example/app?user=app&password=hunter2")); - assertTrue(DevTools.secret("DATABASE_URL", "postgres://app:hunter2@db/app")); - assertFalse(DevTools.secret("cn1.server.port", "8080")); - assertFalse(DevTools.secret("cn1.datasource.url", "sqlite:app.db")); - } } diff --git a/maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java b/maven/backend/src/test/java/com/codename1/impl/backend/JsonCodecTest.java similarity index 99% rename from maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java rename to maven/backend/src/test/java/com/codename1/impl/backend/JsonCodecTest.java index 2c85acf963a..829f760814a 100644 --- a/maven/backend/src/test/java/com/codename1/backend/JsonCodecTest.java +++ b/maven/backend/src/test/java/com/codename1/impl/backend/JsonCodecTest.java @@ -20,7 +20,8 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/maven/backend/src/test/java/com/codename1/impl/backend/SchedulerLeaseTest.java b/maven/backend/src/test/java/com/codename1/impl/backend/SchedulerLeaseTest.java new file mode 100644 index 00000000000..6ac727e1bee --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/impl/backend/SchedulerLeaseTest.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Database; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The scheduler's database leases, which keep one replica running a locked job. +class SchedulerLeaseTest { + @Test + @DisplayName("a claim written after its lease ran out does not run the job") + void aClaimThatLandsExpiredIsNotALease(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + final String path = new java.io.File(dir, "late.db").getAbsolutePath(); + DataSource pool = DataSource.open(path, 2, 5000, 10000); + try { + Scheduler scheduler = new Scheduler(pool); + Runnable nothing = new Runnable() { + public void run() { + } + }; + Scheduler.Job job = new Scheduler.Job("late", Scheduler.FIXED_DELAY, null, 1000, 0, + null, BackendAccess.PLATFORM, "late", 300, nothing); + // Creates the table and the row, then frees it. + scheduler.release(job, scheduler.claim(job)); + // Another writer holds the database for longer than the lease, so the + // claim's write waits past it, as a replica waiting on a lock would. + final java.util.concurrent.CountDownLatch holding = + new java.util.concurrent.CountDownLatch(1); + Thread blocker = new Thread() { + public void run() { + try { + Database db = Database.open(path); + db.execute("BEGIN IMMEDIATE", null); + holding.countDown(); + Thread.sleep(700); + db.execute("COMMIT", null); + db.close(); + } catch (Exception err) { + holding.countDown(); + } + } + }; + blocker.start(); + assertTrue(holding.await(5, TimeUnit.SECONDS)); + long started = System.currentTimeMillis(); + assertNull(scheduler.claim(job), + "a claim written after its lease had run out was returned as a lease"); + assertTrue(System.currentTimeMillis() - started >= 300, + "the claim did not wait on the held lock, so this proved nothing"); + blocker.join(5000); + } finally { + pool.close(); + } + } + + @Test + @DisplayName("an expired run releases only its own lease, not a later run's") + void anExpiredRunDoesNotReleaseItsSuccessor(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + DataSource pool = DataSource.open(new java.io.File(dir, "l.db").getAbsolutePath(), + 2, 5000, 10000); + try { + Scheduler scheduler = new Scheduler(pool); + Runnable nothing = new Runnable() { + public void run() { + } + }; + // Long enough to still be live once the claim is written -- a claim + // that lands already expired is refused -- and short enough to run out. + Scheduler.Job slow = new Scheduler.Job("slow", Scheduler.FIXED_DELAY, null, 1000, 0, + null, BackendAccess.PLATFORM, "shared", 200, nothing); + Scheduler.Job next = new Scheduler.Job("next", Scheduler.FIXED_DELAY, null, 1000, 0, + null, BackendAccess.PLATFORM, "shared", 60000, nothing); + String expired = scheduler.claim(slow); + assertNotNull(expired); + Thread.sleep(260); // the lease runs out + String current = scheduler.claim(next); + assertNotNull(current, "an expired lease was not taken over"); + scheduler.release(slow, expired); // the overrun finally ends + assertNull(scheduler.claim(slow), + "the overrun released the lease a later run now holds"); + scheduler.release(next, current); + assertNotNull(scheduler.claim(slow), "releasing its own lease freed nothing"); + } finally { + pool.close(); + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/impl/backend/mcp/DevToolsTest.java b/maven/backend/src/test/java/com/codename1/impl/backend/mcp/DevToolsTest.java new file mode 100644 index 00000000000..626a460048e --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/impl/backend/mcp/DevToolsTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.mcp; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Which SQLite pragmas backend_sql runs without write=true. */ +class DevToolsTest { + + @Test + @DisplayName("only pragmas that read run unconfirmed, in either spelling") + void pragmaAllowList() { + assertTrue(DevTools.readOnlyPragma("PRAGMA table_info(notes)")); + assertTrue(DevTools.readOnlyPragma("pragma main.table_list")); + assertTrue(DevTools.readOnlyPragma("PRAGMA busy_timeout")); + assertFalse(DevTools.readOnlyPragma("PRAGMA busy_timeout(0)"), + "a setting given in parentheses was run unconfirmed"); + assertFalse(DevTools.readOnlyPragma("PRAGMA cache_size = 123")); + assertFalse(DevTools.readOnlyPragma("PRAGMA writable_schema(ON)")); + assertFalse(DevTools.readOnlyPragma("PRAGMA optimize")); + } + + @Test + @DisplayName("credentials in exporter headers and URL queries are masked") + void credentialsInValuesAreMasked() { + assertTrue(DevTools.secret("cn1.otel.headers", "api-key=abc123")); + assertTrue(DevTools.secret("cn1.datasource.url", + "postgres://db.example/app?user=app&password=hunter2")); + assertTrue(DevTools.secret("DATABASE_URL", "postgres://app:hunter2@db/app")); + assertFalse(DevTools.secret("cn1.server.port", "8080")); + assertFalse(DevTools.secret("cn1.datasource.url", "sqlite:app.db")); + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java b/maven/backend/src/test/java/com/codename1/impl/backend/mcp/McpDispatchTest.java similarity index 98% rename from maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java rename to maven/backend/src/test/java/com/codename1/impl/backend/mcp/McpDispatchTest.java index fe87a117108..bf567360403 100644 --- a/maven/backend/src/test/java/com/codename1/backend/mcp/McpDispatchTest.java +++ b/maven/backend/src/test/java/com/codename1/impl/backend/mcp/McpDispatchTest.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.mcp; +package com.codename1.impl.backend.mcp; import com.codename1.backend.Config; import org.junit.jupiter.api.DisplayName; diff --git a/maven/build-engine/pom.xml b/maven/build-engine/pom.xml index f7f2d647d1a..646486b83f7 100644 --- a/maven/build-engine/pom.xml +++ b/maven/build-engine/pom.xml @@ -255,6 +255,14 @@ ${project.version} test + + + com.codenameone + codenameone-backend-test + ${project.version} + test + diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java index 3216536a05f..dfaa260fc8d 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java @@ -241,18 +241,53 @@ public File execute() throws BuildExecutionException { } unzip(javaApiJar, javaApi, null); + prepareSources(work, runtimeVersion); compile(jdk, javaApi, runtimeSources, classes); generateControllers(classes, work); + afterGenerate(jdk, javaApi, classes, work); requireMainClass(classes); translate(jdk, compilerJar, javaApi, classes, nativeSources, translated, dependencyClasses); - File binary = output != null ? output - : new File(host.buildDirectory().getPath(), binaryName()); + File binary = binaryFile(); link(translated, binary); - getLog().info("built " + binary); + afterLink(binary); return binary; } + // ---- Where a packager built on this one adds to the steps above. ---- + // BackendTestPackager uses them to compile a module's tests into the same + // translation and run the binary that comes out. + + /// Source trees compiled beside the module's and the runtime's. + private final List extraSources = new ArrayList(); + + /// Adds a source tree to the compile. + protected void addSources(File dir) { + extraSources.add(dir); + } + + /// Before the compile, with the runtime version resolved: unpack more sources + /// here and add them with [#addSources]. + protected void prepareSources(File work, String runtimeVersion) throws BuildExecutionException { + } + + /// After the routers and the wiring are generated into `classes`, before the + /// translator reads it. + protected void afterGenerate(File jdk, File javaApi, File classes, File work) + throws BuildExecutionException { + } + + /// Where the binary goes. + protected File binaryFile() { + return output != null ? output + : new File(host.buildDirectory().getPath(), binaryName()); + } + + /// After the binary is linked. + protected void afterLink(File binary) throws BuildExecutionException { + getLog().info("built " + binary); + } + /** * Compiles the module's sources and the runtime's against the JavaAPI as the * BOOTCLASSPATH. See the class comment for why that matters. @@ -342,6 +377,18 @@ private void generateControllers(File classes, File work) throws BuildExecutionE } throw new BuildExecutionException(sb.toString()); } + // Written into the tree, as the process-annotations goal writes them into + // target/classes: the compiled test run reads the wiring record back. + for (Map.Entry resource : ctx.getEmittedResources().entrySet()) { + File file = new File(classes, resource.getKey()); + try { + java.nio.file.Files.createDirectories(file.getParentFile().toPath()); + java.nio.file.Files.write(file.toPath(), resource.getValue()); + } catch (IOException err) { + throw new BuildExecutionException("Could not write " + file + ": " + + err.getMessage(), err); + } + } byte[] generated = ctx.getEmittedResources() .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE); if (generated == null) { @@ -412,6 +459,9 @@ private void compile(File jdk, File javaApi, File runtimeSources, File classes) collectJava(new File(String.valueOf(root)), sources); } collectJava(runtimeSources, sources); + for (File extra : extraSources) { + collectJava(extra, sources); + } if (sources.isEmpty()) { throw new BuildFailureException("No Java sources to compile"); } @@ -568,7 +618,7 @@ private static void copyFile(File from, File to) throws IOException { } } - private List compileClasspathWithoutRuntime() throws BuildExecutionException { + protected List compileClasspathWithoutRuntime() throws BuildExecutionException { List classpath = new ArrayList(); try { for (Object element : host.compileClasspathElements()) { @@ -1071,7 +1121,7 @@ private static boolean hasJavac(File home) { * given, because the C belongs in the translator's source root rather than on * the Java source path. */ - private void unzip(File jar, File javaTarget, File nativeTarget) + protected void unzip(File jar, File javaTarget, File nativeTarget) throws BuildExecutionException { unzip(jar, javaTarget, nativeTarget, false); } @@ -1220,7 +1270,7 @@ private void copyDirectoryFirstWins(File from, File to, File nativeTarget) } } - private void copyDirectory(File from, File to) throws BuildExecutionException { + protected void copyDirectory(File from, File to) throws BuildExecutionException { File[] children = from.listFiles(); if (children == null) { return; @@ -1245,7 +1295,7 @@ private void copyDirectory(File from, File to) throws BuildExecutionException { } } - private void collectJava(File dir, List out) { + protected void collectJava(File dir, List out) { File[] children = dir.listFiles(); if (children == null) { return; @@ -1259,7 +1309,7 @@ private void collectJava(File dir, List out) { } } - private void run(List command, File directory, String what) + protected void run(List command, File directory, String what) throws BuildExecutionException, BuildFailureException { try { ProcessBuilder builder = new ProcessBuilder(command); @@ -1300,7 +1350,7 @@ private void run(List command, File directory, String what) * build ships the previous implementation and says nothing. Checking the * result rather than each delete catches every reason one can survive. */ - private static void emptyDirs(File... dirs) throws BuildExecutionException { + protected static void emptyDirs(File... dirs) throws BuildExecutionException { for (File dir : dirs) { deleteTree(dir); if (dir == null || !dir.exists()) { @@ -1333,7 +1383,7 @@ private static void deleteTree(File file) { file.delete(); } - private static void mkdirs(File... dirs) { + protected static void mkdirs(File... dirs) { for (File dir : dirs) { if (dir != null && !dir.isDirectory()) { dir.mkdirs(); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendTestPackager.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendTestPackager.java new file mode 100644 index 00000000000..f205c7460fc --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendTestPackager.java @@ -0,0 +1,533 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.ProjectHost; +import com.codename1.maven.processors.BackendTests; +import com.codename1.maven.processors.RestControllerAnnotationProcessor; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/// Runs a backend module's tests as a native binary: the packaging +/// [BackendPackager] does, with the tests compiled into the same translation. +/// +/// The same test classes the JVM runs, translated with the application by +/// ParparVM and linked the way the server is -- so what is tested is the program +/// that ships, generated wiring, woven aspects, natives and all. There is no JUnit +/// and no reflection in that binary: the test pass finds the tests in the bytecode +/// and generates the calls JUnit would make (see `BackendTestGenerator`), and the +/// tests compile against a subset of JUnit 5's API that codenameone-backend-test +/// ships for the purpose. +/// +/// What a compiled run cannot do, it reports rather than fails on: a test class +/// that imports Mockito is left out of the compile (a mocking library builds +/// classes at run time), and a class using `@MockitoBean` is reported as skipped. +/// [#strict] turns either into a failure. +/// +/// The results land in the build directory's surefire-reports as +/// `TEST--compiled.xml`, beside the JVM run's, so a CI report shows both. +public abstract class BackendTestPackager extends BackendPackager { + /// Fail on a test left out of the run or skipped in it. + protected boolean strict; + /// How long the test binary may run. + protected int timeoutSeconds = 900; + + /// Test sources left out of the compile, and why. + private final Map excluded = new LinkedHashMap(); + + /// A packager for the backend `host` describes. + protected BackendTestPackager(ProjectHost host) { + super(host); + } + + /// The module's test source roots. + protected abstract List testSourceRoots(); + + /// The test resources as the build tool processed them, or null. + protected abstract File testOutputDirectory(); + + /// Whether the JVM run discovers the test class `binaryName`, so the compiled + /// run runs it too. Every class by default, as Gradle's JUnit Platform scan + /// finds them; the Maven goal answers with Surefire's includes and excludes. + protected boolean selectsTestClass(String binaryName) { + return true; + } + + /// Whether the JVM run runs test `method` of the selected class `binaryName`: + /// all of them by default; the Maven goal narrows them by `-Dtest=Class#method`. + protected boolean selectsTestMethod(String binaryName, String method) { + return true; + } + + /// The selection the test pass applies: a class name, or `Class#method` for one + /// of its tests. + private boolean selects(String name) { + int hash = name.indexOf('#'); + return hash < 0 ? selectsTestClass(name) + : selectsTestMethod(name.substring(0, hash), name.substring(hash + 1)); + } + + /// Sets [#strict]. + public BackendTestPackager strict(boolean value) { + this.strict = value; + return this; + } + + /// Sets [#timeoutSeconds]. + public BackendTestPackager timeoutSeconds(int value) { + this.timeoutSeconds = value; + return this; + } + + /// Whether the module has any test source to compile. + public boolean hasTests() { + // Kotlin sources count: a suite of only Kotlin tests is one the compiled run + // cannot compile, and must say so -- reported as "no test sources" it was + // silently left out, strict mode or not. + return !testSources().isEmpty() || !kotlinTestSources().isEmpty(); + } + + @Override + protected void prepareSources(File work, String runtimeVersion) throws BuildExecutionException { + File testRuntime = new File(work, "test-runtime-src"); + emptyDirs(testRuntime); + mkdirs(testRuntime); + File jar = resolve("com.codenameone", "codenameone-backend-test", runtimeVersion, + "parparvm-sources"); + unzip(jar, testRuntime, null); + addSources(testRuntime); + } + + private static final java.util.regex.Pattern MOCKITO = + java.util.regex.Pattern.compile("\\borg\\s*\\.\\s*mockito\\b"); + + /// Whether Java `source` uses Mockito in its code -- an import or a qualified + /// name. Comments and string or character literals are blanked out first: the + /// words in a comment, a Javadoc or a fixture string left an otherwise + /// translatable test file out of the compiled run. + static boolean usesMockito(String source) { + StringBuilder code = new StringBuilder(source.length()); + int n = source.length(); + int i = 0; + while (i < n) { + char c = source.charAt(i); + char next = i + 1 < n ? source.charAt(i + 1) : 0; + if (c == '/' && next == '/') { + while (i < n && source.charAt(i) != '\n') { + i++; + } + } else if (c == '/' && next == '*') { + int end = source.indexOf("*/", i + 2); + i = end < 0 ? n : end + 2; + code.append(' '); + } else if (c == '"' && source.startsWith("\"\"\"", i)) { + int end = source.indexOf("\"\"\"", i + 3); + i = end < 0 ? n : end + 3; + code.append(' '); + } else if (c == '"' || c == '\'') { + i++; + while (i < n && source.charAt(i) != c && source.charAt(i) != '\n') { + i += source.charAt(i) == '\\' ? 2 : 1; + } + i++; + code.append(' '); + } else { + code.append(c); + i++; + } + } + return MOCKITO.matcher(code).find(); + } + + @Override + protected void afterGenerate(File jdk, File javaApi, File classes, File work) + throws BuildExecutionException { + File testClasses = new File(work, "test-classes"); + emptyDirs(testClasses); + mkdirs(testClasses); + // The tests are compiled here with javac, so Kotlin ones cannot be: refused + // by name rather than left out, which ran a Kotlin project's compiled tests + // as "no test source" and a mixed one's without its Kotlin tests at all. + List kotlin = kotlinTestSources(); + if (!kotlin.isEmpty()) { + throw new BuildFailureException("The compiled backend test run compiles Java test " + + "sources, and these are Kotlin: " + kotlin + ". Run them on the JVM, or " + + "write the tests to run compiled in Java."); + } + List sources = compilableSources(); + if (!excluded.isEmpty()) { + String list = excluded.toString(); + if (strict) { + throw new BuildFailureException("These tests cannot be compiled: " + list); + } + getLog().warn("cn1: left out of the compiled test run: " + list); + } + if (sources.isEmpty()) { + throw new BuildFailureException("No test source can be compiled for a native run"); + } + List classpath = new ArrayList(); + classpath.add(classes.getAbsolutePath()); + classpath.addAll(compileClasspathWithoutRuntime()); + List command = new ArrayList(Arrays.asList( + new File(jdk, "bin/javac").getAbsolutePath(), + "-nowarn", "-encoding", sourceEncoding(), + "-bootclasspath", javaApi.getAbsolutePath(), + "-source", "1.8", "-target", "1.8", + "-classpath", join(classpath, File.pathSeparator), + // The selected tests are named; whatever else of the test sources + // they use -- a helper, a base class, a configuration -- javac + // finds here and compiles too. A test the run did not select is + // not compiled at all, so one using an API the server runtime + // lacks cannot fail a run that never executes it. + "-sourcepath", join(testSourceRoots(), File.pathSeparator), + "-implicit:class", + "-d", testClasses.getAbsolutePath())); + command.addAll(sources); + run(command, host.baseDir(), "compile the tests against the backend's class " + + "library (a test that uses a JDK class the server runtime lacks fails here)"); + File processed = testOutputDirectory(); + if (processed != null && processed.isDirectory()) { + copyResources(processed, testClasses); + } + List roots = new ArrayList(host.compileSourceRoots()); + roots.addAll(testSourceRoots()); + List cp = new ArrayList(classpath); + cp.add(javaApi.getAbsolutePath()); + BackendTests.process(classes, testClasses, new File(work, "test-stubs"), + host.baseDir(), roots, sourceEncoding(), cp, true, this::selects, getLog()); + copyDirectory(testClasses, classes); + // One main per translation: the app's generated entry point goes, and the + // test runner's takes its place. No test calls it; a test starts the + // application through its context instead. + File marker = new File(classes, RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE); + if (marker.isFile()) { + try { + String entry = new String(Files.readAllBytes(marker.toPath()), + StandardCharsets.UTF_8).trim(); + File entryClass = new File(classes, entry.replace('.', '/') + ".class"); + if (entryClass.isFile() && !entryClass.delete()) { + throw new BuildExecutionException("Could not remove " + entryClass + + " from the test translation"); + } + } catch (IOException err) { + throw new BuildExecutionException("Could not read " + marker, err); + } + } + mainClass = BackendTests.MAIN_CLASS; + } + + @Override + protected File binaryFile() { + File dir = new File(host.buildDirectory(), "cn1-backend-test"); + mkdirs(dir); + return new File(dir, binaryName() + "-tests"); + } + + @Override + protected void afterLink(File binary) throws BuildExecutionException { + File log = new File(binary.getParentFile(), "test-output.txt"); + int status; + try { + ProcessBuilder builder = new ProcessBuilder(binary.getAbsolutePath()); + builder.directory(host.baseDir()); + builder.redirectErrorStream(true); + builder.redirectOutput(log); + Process process = builder.start(); + if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new BuildFailureException("The compiled tests did not finish in " + + timeoutSeconds + " seconds; their output is in " + log); + } + status = process.exitValue(); + } catch (IOException err) { + throw new BuildExecutionException("Could not run " + binary, err); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new BuildExecutionException("Interrupted while running the compiled tests", err); + } + List lines; + try { + lines = Files.readAllLines(log.toPath(), StandardCharsets.UTF_8); + } catch (IOException err) { + throw new BuildExecutionException("Could not read " + log, err); + } + CompiledTestReport report = CompiledTestReport.parse(lines); + for (String line : lines) { + if (!line.startsWith("CN1TEST\t")) { + getLog().info("[compiled] " + line); + } + } + File reports = new File(host.buildDirectory(), "surefire-reports"); + try { + report.write(reports); + } catch (IOException err) { + throw new BuildExecutionException("Could not write the test reports: " + + err.getMessage(), err); + } + getLog().info("cn1: compiled tests: " + report.passed + " passed, " + report.failed + + " failed, " + report.skipped + " skipped"); + if (strict && report.skipped > 0) { + throw new BuildFailureException(report.skipped + " compiled test(s) were skipped and " + + "strict mode is on:\n" + report.skips()); + } + if (!report.done) { + throw new BuildFailureException("The compiled tests stopped before reporting their " + + "totals (exit " + status + "): the binary crashed. Its output is in " + log); + } + if (report.failed > 0 || status != 0) { + throw new BuildFailureException(report.failed + " compiled test(s) failed:\n" + + report.failures()); + } + } + + private List kotlinTestSources() { + List out = new ArrayList(); + for (String root : testSourceRoots()) { + collectKotlin(new File(root), out); + } + return out; + } + + private static void collectKotlin(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectKotlin(child, out); + } else if (child.getName().endsWith(".kt")) { + out.add(child.getName()); + } + } + } + + /// Runs the compiled tests -- or, when nothing selected can be compiled and + /// strict is off, says so and returns null. Leaving out a Mockito test is a + /// warning in that mode, so a module whose tests all use Mockito, or a + /// selection that names none of the module's tests, is a run with nothing in + /// it rather than a failed build. + @Override + public File execute() throws BuildExecutionException { + clearCompiledReports(); + if (!strict && kotlinTestSources().isEmpty() && !testSources().isEmpty() + && compilableSources().isEmpty()) { + getLog().warn("cn1: no compiled backend tests to run" + + (excluded.isEmpty() ? ": the selection names none of this module's tests" + : "; left out: " + excluded)); + excluded.clear(); + return null; + } + excluded.clear(); + return super.execute(); + } + + /// Deletes the compiled-run reports an earlier run left in surefire-reports: + /// a targeted run (`-Dtest=ApiTest`) writes reports only for what it ran, and + /// the rest from a previous full run read as if they had run this time. + private void clearCompiledReports() { + File[] old = new File(host.buildDirectory(), "surefire-reports").listFiles(); + if (old == null) { + return; + } + for (File f : old) { + String name = f.getName(); + if (name.startsWith("TEST-") && name.endsWith("-compiled.xml") && !f.delete()) { + getLog().warn("cn1: could not remove the stale report " + f); + } + } + } + + /// The test sources the compiled run names to javac: the ones whose class the + /// JVM run selects, less those that use Mockito, which go into [#excluded]. + private List compilableSources() throws BuildExecutionException { + List sources = new ArrayList(); + Map> declared = declaredClasses(testOutputDirectory()); + for (String root : testSourceRoots()) { + File base = new File(root); + List found = new ArrayList(); + collectJava(base, found); + for (String path : found) { + File f = new File(path); + if (!selects(declared.get(sourcePath(base, f)), binaryName(base, f))) { + continue; + } + String text; + try { + text = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } catch (IOException err) { + throw new BuildExecutionException("Could not read " + f, err); + } + if (usesMockito(text)) { + excluded.put(f.getName(), "imports Mockito, which runs only on the JVM"); + continue; + } + sources.add(f.getAbsolutePath()); + } + } + return sources; + } + + /// Whether the JVM run selects a source: by the top-level classes compiled + /// from it, which is what Surefire matches -- `Fixtures.java` may declare a + /// package-private `class ApiTest` -- or by its file name when those are not + /// known. + private boolean selects(List classes, String byFileName) { + if (classes == null || classes.isEmpty()) { + return selectsTestClass(byFileName); + } + for (String name : classes) { + if (selectsTestClass(name)) { + return true; + } + } + return false; + } + + /// The top-level classes compiled from each test source, keyed by the + /// source's path under its root (`com/acme/Fixtures.java`), read from the + /// JVM build's test classes; empty when there are none to read. + static Map> declaredClasses(File testClasses) { + Map> out = new LinkedHashMap>(); + if (testClasses == null || !testClasses.isDirectory()) { + return out; + } + List files = new ArrayList(); + collectClasses(testClasses, files); + for (File f : files) { + final String[] found = new String[2]; + try { + new org.objectweb.asm.ClassReader(Files.readAllBytes(f.toPath())).accept( + new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public void visit(int version, int access, String name, String signature, + String superName, String[] interfaces) { + found[0] = name; + } + + @Override + public void visitSource(String source, String debug) { + found[1] = source; + } + }, org.objectweb.asm.ClassReader.SKIP_CODE); + } catch (IOException | RuntimeException unreadable) { + continue; + } + if (found[0] == null || found[1] == null || found[0].indexOf('$') >= 0) { + continue; + } + int slash = found[0].lastIndexOf('/'); + String key = (slash < 0 ? "" : found[0].substring(0, slash + 1)) + found[1]; + List classes = out.get(key); + if (classes == null) { + classes = new ArrayList(); + out.put(key, classes); + } + classes.add(found[0].replace('/', '.')); + } + return out; + } + + private static void collectClasses(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectClasses(child, out); + } else if (child.getName().endsWith(".class")) { + out.add(child); + } + } + } + + /// A source file's path under `root`, with `/` separators. + static String sourcePath(File root, File source) { + return root.toURI().relativize(source.toURI()).getPath(); + } + + /// The binary name of the class a source file under `root` declares. + static String binaryName(File root, File source) { + String relative = root.toURI().relativize(source.toURI()).getPath(); + if (relative.endsWith(".java")) { + relative = relative.substring(0, relative.length() - ".java".length()); + } + return relative.replace('/', '.'); + } + + private List testSources() { + List out = new ArrayList(); + for (String root : testSourceRoots()) { + List found = new ArrayList(); + collectJava(new File(root), found); + for (String f : found) { + out.add(new File(f)); + } + } + return out; + } + + private static void copyResources(File from, File to) throws BuildExecutionException { + File[] children = from.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + File target = new File(to, child.getName()); + if (child.isDirectory()) { + copyResources(child, target); + } else if (!child.getName().endsWith(".class")) { + try { + Files.createDirectories(to.toPath()); + Files.copy(child.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException err) { + throw new BuildExecutionException("Could not copy " + child, err); + } + } + } + } + + private static String join(List parts, String separator) { + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (sb.length() > 0) { + sb.append(separator); + } + sb.append(p); + } + return sb.toString(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/CompiledTestReport.java b/maven/build-engine/src/main/java/com/codename1/maven/CompiledTestReport.java new file mode 100644 index 00000000000..d348d700cf2 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/CompiledTestReport.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * What a compiled backend test binary reported, read from its CN1TEST lines (see + * {@code com.codename1.impl.backend.test.TestRun}) and written as Surefire XML. + */ +final class CompiledTestReport { + int passed; + int failed; + int skipped; + boolean done; + /** class -> its cases, as {kind, method, millis, type, message}. */ + final Map> cases = new LinkedHashMap>(); + + static CompiledTestReport parse(List lines) { + CompiledTestReport r = new CompiledTestReport(); + for (String line : lines) { + if (!line.startsWith("CN1TEST\t")) { + continue; + } + String[] f = line.split("\t", -1); + if ("DONE".equals(f[1])) { + r.done = true; + continue; + } + if (f.length < 5) { + continue; + } + String kind = f[1]; + String[] c = {kind, unescape(f[3]), f[4], f.length > 5 ? f[5] : null, + f.length > 6 ? unescape(f[6]) : null}; + List list = r.cases.get(f[2]); + if (list == null) { + list = new ArrayList(); + r.cases.put(f[2], list); + } + list.add(c); + if ("PASS".equals(kind)) { + r.passed++; + } else if ("FAIL".equals(kind)) { + r.failed++; + } else if ("SKIP".equals(kind)) { + r.skipped++; + } + } + return r; + } + + String failures() { + return list("FAIL"); + } + + String skips() { + return list("SKIP"); + } + + private String list(String kind) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry> e : cases.entrySet()) { + for (String[] c : e.getValue()) { + if (kind.equals(c[0])) { + sb.append(" ").append(e.getKey()).append('.').append(c[1]).append(": ") + .append(c[4]).append('\n'); + } + } + } + return sb.toString(); + } + + void write(File dir) throws IOException { + Files.createDirectories(dir.toPath()); + for (Map.Entry> e : cases.entrySet()) { + int f = 0; + int s = 0; + long total = 0; + for (String[] c : e.getValue()) { + f += "FAIL".equals(c[0]) ? 1 : 0; + s += "SKIP".equals(c[0]) ? 1 : 0; + total += parseLong(c[2]); + } + StringBuilder sb = new StringBuilder("\n"); + sb.append("\n"); + for (String[] c : e.getValue()) { + sb.append(" \n ").append(xml(c[4])).append("\n") + .append(" \n"); + } else if ("SKIP".equals(c[0])) { + sb.append(">\n \n") + .append(" \n"); + } else { + sb.append("/>\n"); + } + } + sb.append("\n"); + Files.write(new File(dir, "TEST-" + e.getKey() + "-compiled.xml").toPath(), + sb.toString().getBytes(StandardCharsets.UTF_8)); + } + } + + private static long parseLong(String value) { + try { + return Long.parseLong(value.trim()); + } catch (RuntimeException err) { + return 0; + } + } + + private static String seconds(long millis) { + return String.valueOf(millis / 1000.0); + } + + static String unescape(String value) { + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\' && i + 1 < value.length()) { + char n = value.charAt(++i); + sb.append(n == 't' ? '\t' : n == 'n' ? '\n' : n == 'r' ? '\r' : n); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String xml(String value) { + if (value == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '<': sb.append("<"); break; + case '>': sb.append(">"); break; + case '&': sb.append("&"); break; + case '"': sb.append("""); break; + case '\n': sb.append(" "); break; + case '\t': sb.append(" "); break; + default: + if (c < 0x20 && c != '\r') { + sb.append(' '); + } else { + sb.append(c); + } + } + } + return sb.toString(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java b/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java index cf6a2f26e49..7f2f8280d9d 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/GradleConversion.java @@ -88,11 +88,12 @@ public GradleConversion includeUntouchedBackend(boolean include) { } /// Whether `backend` is exactly the generated skeleton: the generated `Api` - /// controller with its routes, the `Greeter` service it is given, and nothing - /// else. + /// controller with its routes, the `Greeter` service it is given, the two + /// sample tests of them when the project has them, and nothing else. static boolean isUntouchedSkeleton(File backend) { // The generated sources and nothing else: Api.java and Greeter.java side by - // side, no Kotlin, no resources, no extra profiles. + // side, no Kotlin, no resources, no extra profiles. A project generated + // before the archetype carried tests has none, and is just as untouched. List sources = new ArrayList(); collectFiles(new File(backend, "src"), sources); Map byName = new HashMap(); @@ -101,8 +102,12 @@ static boolean isUntouchedSkeleton(File backend) { } File api = byName.get("Api.java"); File greeter = byName.get("Greeter.java"); - if (sources.size() != 2 || api == null || greeter == null - || !api.getParentFile().equals(greeter.getParentFile())) { + File apiTest = byName.get("ApiTest.java"); + File servedTest = byName.get("ServedApiTest.java"); + boolean tests = apiTest != null && servedTest != null; + if (sources.size() != (tests ? 4 : 2) || api == null || greeter == null + || !api.getParentFile().equals(greeter.getParentFile()) + || (tests && !apiTest.getParentFile().equals(servedTest.getParentFile()))) { return false; } String[] profiles = backend.list((d, n) -> n.startsWith("application-") && n.endsWith(".properties") @@ -120,6 +125,10 @@ static boolean isUntouchedSkeleton(File backend) { if (!sameCode(api, "backend/Api.java.txt") || !sameCode(greeter, "backend/Greeter.java.txt")) { return false; } + if (tests && (!sameCode(apiTest, "backend/ApiTest.java.txt") + || !sameCode(servedTest, "backend/ServedApiTest.java.txt"))) { + return false; + } return sameSettings(new File(backend, "application.properties"), "backend/application.properties.txt") && sameSettings(new File(backend, "application-dev.properties"), "backend/application-dev.properties.txt"); @@ -131,9 +140,11 @@ && sameSettings(new File(backend, "application-dev.properties"), /// The dependencies and build plugins the archetype's backend pom declares, /// as group:artifact. Kept in step with cn1app-archetype's backend/pom.xml. private static final java.util.Set GENERATED_BACKEND_DEPENDENCIES = new java.util.HashSet( - java.util.Arrays.asList("com.codenameone:codenameone-backend", "org.xerial:sqlite-jdbc")); + java.util.Arrays.asList("com.codenameone:codenameone-backend", "org.xerial:sqlite-jdbc", + "com.codenameone:codenameone-backend-test", "org.junit.jupiter:junit-jupiter")); private static final java.util.Set GENERATED_BACKEND_PLUGINS = new java.util.HashSet( - java.util.Arrays.asList("com.codenameone:codenameone-maven-plugin")); + java.util.Arrays.asList("com.codenameone:codenameone-maven-plugin", + "org.apache.maven.plugins:maven-surefire-plugin")); /// Whether a backend's pom is still what the archetype wrote, as far as the /// conversion carries anything over: no dependency, build plugin, diff --git a/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java b/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java index e4c03d6ab76..ba916c69f59 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/GradleProjectTemplate.java @@ -116,8 +116,8 @@ public static void writeScaffolding(File dir, String projectName, String cn1Vers } /// Writes a backend skeleton into `backendDir`: its `application*.properties` - /// and, when `packageName` is not null, an `Api` controller and the `Greeter` - /// service it is given, in that package. + /// and, when `packageName` is not null, an `Api` controller, the `Greeter` + /// service it is given and the tests of both, in that package. /// /// @param taskPrefix how the backend's tasks are addressed from the root: /// `:backend:` for a subproject, empty for a backend-only project @@ -132,6 +132,11 @@ public static void writeBackendFiles(File backendDir, String packageName, String .replace("__BACKEND__", taskPrefix)); write(new File(backendDir, "src/main/java/" + packageName.replace('.', '/') + "/Greeter.java"), text("backend/Greeter.java.txt").replace("${package}", packageName)); + for (String test : new String[] {"ApiTest", "ServedApiTest"}) { + write(new File(backendDir, "src/test/java/" + packageName.replace('.', '/') + "/" + test + ".java"), + text("backend/" + test + ".java.txt").replace("${package}", packageName) + .replace("__BACKEND__", taskPrefix)); + } } } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java index 6e3f8713129..dc7721c4657 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java @@ -153,6 +153,7 @@ public String getSourceName() { public boolean isAbstract() { return (access & Opcodes.ACC_ABSTRACT) != 0; } public boolean isInterface() { return (access & Opcodes.ACC_INTERFACE) != 0; } + public boolean isAnnotation() { return (access & Opcodes.ACC_ANNOTATION) != 0; } public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } public boolean isFinal() { return (access & Opcodes.ACC_FINAL) != 0; } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/MethodInfo.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/MethodInfo.java index 0009bd59641..e884694e49d 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/MethodInfo.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/MethodInfo.java @@ -99,6 +99,7 @@ public final class MethodInfo { public int getAccess() { return access; } public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } + public boolean isProtected() { return (access & Opcodes.ACC_PROTECTED) != 0; } public boolean isStatic() { return (access & Opcodes.ACC_STATIC) != 0; } public boolean isAbstract() { return (access & Opcodes.ACC_ABSTRACT) != 0; } public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java index 38891479cbd..46dcce0a0ad 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendBeans.java @@ -48,8 +48,8 @@ /// The backend's beans, resolved at build time. /// -/// Every class carrying a stereotype -- `@Component`, `@Service`, `@Repository`, -/// `@Configuration`, `@RestController`, `@WebSocketMapping` -- and every `@Bean` +/// Every class carrying a stereotype -- `@Component`, `@Configuration`, +/// `@RestController`, `@WebSocketMapping` -- and every `@Bean` /// method is a bean. This works out, for each one, which constructor the /// generated entry point calls and what it passes, which bean every `@Autowired` /// field and setter receives, in what order they are built, and which of their @@ -71,8 +71,6 @@ final class BackendBeans { static final String PKG = "Lcom/codename1/backend/annotations/"; static final String COMPONENT = PKG + "Component;"; - static final String SERVICE = PKG + "Service;"; - static final String REPOSITORY = PKG + "Repository;"; static final String CONFIGURATION = PKG + "Configuration;"; static final String BEAN = PKG + "Bean;"; static final String AUTOWIRED = PKG + "Autowired;"; @@ -82,8 +80,6 @@ final class BackendBeans { static final String VALUE = PKG + "Value;"; static final String CONFIG_PROPERTIES = PKG + "ConfigurationProperties;"; static final String SCOPE = PKG + "Scope;"; - static final String REQUEST_SCOPE = PKG + "RequestScope;"; - static final String SESSION_SCOPE = PKG + "SessionScope;"; static final String PROFILE = PKG + "Profile;"; static final String ON_PROPERTY = PKG + "ConditionalOnProperty;"; static final String ON_MISSING = PKG + "ConditionalOnMissingBean;"; @@ -105,14 +101,14 @@ final class BackendBeans { /// Every annotation this pass reads, for the processor's declared interest. static final Set DESCRIPTORS = Collections.unmodifiableSet( - new LinkedHashSet(java.util.Arrays.asList(COMPONENT, SERVICE, REPOSITORY, + new LinkedHashSet(java.util.Arrays.asList(COMPONENT, CONFIGURATION, BEAN, AUTOWIRED, VALUE, CONFIG_PROPERTIES, SCOPE, - REQUEST_SCOPE, SESSION_SCOPE, PROFILE, ON_PROPERTY, ON_MISSING, + PROFILE, ON_PROPERTY, ON_MISSING, POST_CONSTRUCT, PRE_DESTROY, TRANSACTIONAL, ASYNC, SCHEDULED, MANAGED_RESOURCE, MANAGED_ATTRIBUTE, MANAGED_OPERATION, TIMED, COUNTED, MCP_TOOL, REST_CONTROLLER, WEBSOCKET_MAPPING))); - private static final String[] STEREOTYPES = {COMPONENT, SERVICE, REPOSITORY, CONFIGURATION, + private static final String[] STEREOTYPES = {COMPONENT, CONFIGURATION, REST_CONTROLLER, WEBSOCKET_MAPPING}; static final String CONFIG_TYPE = "com/codename1/backend/Config"; @@ -287,6 +283,9 @@ static final class Bean { int slot = -1; /// The generated stand-in class, for a request, session or lazy bean. String proxyBinary; + /// For a test's `@MockitoBean`: the internal name of the type mocked. Such a + /// bean has no class of its own to construct; the test wiring asks Mockito. + String mockType; boolean isConditional() { return !profiles.isEmpty() || !propertyConditions.isEmpty(); @@ -389,6 +388,15 @@ static final class Aspect { int lazySlots; /// The package the entry point is written into. String entryPackage; + + /// Test mode, set by [#forTests] and null in a normal build: the classes that + /// may become beans (the application's and the context's test + /// configurations), the test configurations themselves, the mocks as + /// {field, internal type}, and the classes this pass may rewrite. + private Set testScope; + private Set testConfigurations; + private List testMocks; + private Set testWeaveable; /// Sources this pass compiled, by binary name, so tests can read them. final Map sources = new LinkedHashMap(); @@ -430,6 +438,158 @@ static BackendBeans prepare(ProcessorContext ctx) throws ProcessingException { return out; } + /// The beans of one test context: the application's, the context's + /// `@TestConfiguration` beans, and its mocks in place of the beans they + /// replace -- resolved like a build's, but woven only in `weaveable` (the + /// test classes) and with nothing generated beside them. The application's + /// classes were woven, and their stand-ins, adapters and aspects compiled, by + /// the main build; the names computed here are those same names, which the + /// rules below keep stable. + /// + /// @param entryPackage the main build's, from its wiring record + static BackendBeans forTests(ProcessorContext ctx, Set scope, + Set configurations, List mocks, + Set weaveable, String entryPackage) + throws ProcessingException { + BackendBeans out = new BackendBeans(ctx); + out.testScope = scope; + out.testConfigurations = configurations; + out.testMocks = mocks; + out.testWeaveable = weaveable; + out.discover(); + if (entryPackage != null) { + out.entryPackage = entryPackage; + } + if (!ctx.hasErrors()) { + out.checkTestBeans(); + } + if (!ctx.hasErrors()) { + out.applyMocks(); + } + if (!ctx.hasErrors()) { + out.resolve(); + } + if (!ctx.hasErrors()) { + out.plan(); + } + if (!ctx.hasErrors()) { + out.nameAdapters(); + } + return out; + } + + /// Test beans are plain singletons. A scoped or lazy one would take a slot + /// and shift the application's, which the stand-ins compiled by the main + /// build have baked in; a woven one would need an aspect class nothing + /// generates in a test run. + private void checkTestBeans() { + for (Bean b : beans) { + AnnotatedClass declaring = b.factory != null ? b.factoryOwnerClass : b.cls; + if (declaring == null || !testConfigurations.contains(declaring.getInternalName())) { + continue; + } + if (!SINGLETON.equals(b.scope) || b.lazy) { + ctx.error(declaring, "Test bean " + b.describe() + " is " + (b.lazy ? "lazy" + : "scoped " + b.scope) + "; a test bean is a plain singleton."); + } + } + for (String name : testConfigurations) { + AnnotatedClass cls = ctx.lookup(name); + if (cls == null) { + continue; + } + for (MethodInfo m : cls.getMethods()) { + if (m.getAnnotation(TRANSACTIONAL) != null || m.getAnnotation(ASYNC) != null + || m.getAnnotation(TIMED) != null || m.getAnnotation(COUNTED) != null + || m.getAnnotation(SCHEDULED) != null) { + ctx.error(cls, "@TestConfiguration " + cls.getSourceName() + "." + + m.getName() + " carries an annotation the build weaves; a test " + + "configuration declares beans and nothing else."); + } + } + } + } + + /// Replaces every bean assignable to a mocked type with one mock of it. + private void applyMocks() { + for (String[] mock : testMocks) { + String type = mock[1]; + List replaced = new ArrayList(); + for (Bean b : beans) { + if (b.mockType == null && b.types.contains(type)) { + replaced.add(b); + } + } + for (Bean b : replaced) { + if (b.controller || b.webSocket) { + ctx.error("@MockitoBean " + type.replace('/', '.') + " would replace " + + b.describe() + ", which serves requests; mock what it calls " + + "instead."); + return; + } + if (!SINGLETON.equals(b.scope) || b.lazy) { + ctx.error("@MockitoBean " + type.replace('/', '.') + " would replace " + + b.describe() + ", which is " + (b.lazy ? "lazy" : "scoped " + + b.scope) + "; only a singleton can be mocked."); + return; + } + } + for (Bean b : replaced) { + beans.remove(b); + byName.remove(b.name); + } + // Factory beans of a configuration that was mocked away go with it. + for (int i = beans.size() - 1; i >= 0; i--) { + Bean b = beans.get(i); + if (b.owner != null && replaced.contains(b.owner)) { + beans.remove(i); + byName.remove(b.name); + } + } + Bean m = new Bean(); + m.mockType = type; + m.type = type; + m.cls = ctx.lookup(type); + m.name = replaced.size() == 1 ? replaced.get(0).name : mock[0]; + m.types.addAll(assignableTypes(type)); + m.types.add(type); + add(m); + } + } + + /// The adapter class names the wiring registers, as the main build named them. + private void nameAdapters() { + for (Bean b : beans) { + String pkg = RestClientAnnotationProcessor.packageOf(b.type.replace('/', '.')); + for (int i = 0; i < b.tools.size(); i++) { + b.tools.get(i).adapterBinary = qualify(pkg, baseName(b.type) + "Cn1Tool" + i); + } + if (b.managed != null) { + b.managed.adapterBinary = qualify(pkg, baseName(b.type) + "Cn1Managed"); + } + } + } + + /// Weaves the test classes this pass planned, and nothing else, each where its + /// class file is: a mixed Java and Kotlin test set has two output directories. + int weaveTests(File out, Map index) throws ProcessingException { + int woven = 0; + try { + for (BackendWeaver.Plan plan : plans.values()) { + AnnotatedClass cls = index.get(plan.internalName); + File file = cls != null && cls.getClassFile() != null ? cls.getClassFile() + : new File(out, plan.internalName + ".class"); + if (BackendWeaver.weaveFile(file, plan)) { + woven++; + } + } + } catch (IOException err) { + throw new ProcessingException("Could not rewrite the test classes: " + + err.getMessage(), err); + } + return woven; + } + private static boolean usesAnyAnnotation(ProcessorContext ctx) { for (AnnotatedClass cls : ctx.getClassIndex().values()) { for (String d : cls.getAllAnnotationDescriptors()) { @@ -503,7 +663,11 @@ public int compare(AnnotatedClass a, AnnotatedClass b) { if (!concerns(cls)) { continue; } - if (!isStereotyped(cls)) { + if (testScope != null && !testScope.contains(cls.getInternalName())) { + continue; + } + if (!isStereotyped(cls) && !(testConfigurations != null + && testConfigurations.contains(cls.getInternalName()))) { continue; } if (cls.isInterface() || cls.isAbstract()) { @@ -612,8 +776,8 @@ private Bean classBean(AnnotatedClass cls) { // the task calls the instance itself, destroyed or not. String scope = REQUEST.equals(bean.scope) ? "request" : "session"; ctx.getLog().warn("cn1: @Async method " + cls.getSourceName() + "." + m.getName() - + " is on a @" + (REQUEST.equals(bean.scope) ? "Request" : "Session") - + "Scope bean, which is destroyed when its " + scope + " ends -- " + + " is on a @Scope(\"" + scope + "\") bean, which is destroyed when " + + "its " + scope + " ends -- " + "possibly before the task runs. Move the method to a singleton " + "and pass it what it needs."); break; @@ -809,12 +973,6 @@ private void readModifiers(Bean bean, Map annotations, } bean.scope = s; } - if (annotations.get(REQUEST_SCOPE) != null) { - bean.scope = REQUEST; - } - if (annotations.get(SESSION_SCOPE) != null) { - bean.scope = SESSION; - } bean.primary = annotations.get(PRIMARY) != null; AnnotationValues lazy = annotations.get(LAZY); bean.lazy = lazy != null && lazy.getBoolOrDefault("value", true); @@ -1306,7 +1464,7 @@ private void pickEntryPackage() { /// Whether the bean has a method the build runs on an executor: `@Async` on /// the method, or on the class for its public instance methods. private boolean hasAsync(Bean b) { - if (b.cls == null) { + if (b.cls == null || b.mockType != null) { return false; } boolean onClass = b.cls.getClassAnnotation(ASYNC) != null; @@ -1568,7 +1726,7 @@ private void collectTools(Bean bean) { tool.paramTypes.add(declared); tool.paramSchema.add(declared == null ? null : codecs.schemaType(declared)); tool.paramRead.add(declared == null ? null : codecs.readStatements(declared, - "j", "com.codename1.backend.JsonCodec.Path.ROOT", + "j", "com.codename1.impl.backend.JsonCodec.Path.ROOT", BackendJsonCodecs.javaString(paramName), "-1", "0", "target", "")); } Type returned = Type.getReturnType(m.getDescriptor()); @@ -1765,7 +1923,7 @@ private void collectManaged(Bean bean) { "java/util/concurrent/ScheduledFuture", "java/util/concurrent/RunnableScheduledFuture", "java/util/concurrent/FutureTask", "java/util/concurrent/CompletableFuture", "java/util/concurrent/ForkJoinTask", "com/codename1/backend/AsyncResult", - "com/codename1/backend/AsyncTask")); + "com/codename1/impl/backend/AsyncTask")); /// Whether `m` returns a Future: declared as one, or a type implementing it. private boolean returnsFuture(MethodInfo m) { @@ -1998,7 +2156,7 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { if (!REQUEST.equals(owner.scope) && !SESSION.equals(owner.scope)) { ctx.error(where, p.where + " asks for the current " + (REQUEST_TYPE.equals(type) ? "request" : "session") + ", which only " - + "a @RequestScope or @SessionScope bean has. Take it as a parameter of " + + "a @Scope(\"request\") or @Scope(\"session\") bean has. Take it as a parameter of " + "the handler method instead, or scope the bean."); return; } @@ -2057,7 +2215,7 @@ private void resolvePoint(Bean owner, Point p, AnnotatedClass where) { if (p.required) { ctx.error(where, p.where + " needs a " + p.type.getClassName() + ", and no " + "bean has that type. Annotate the implementing class @Component, " - + "@Service or @Repository, or declare a @Bean method returning one."); + + "or declare a @Bean method returning one."); } return; } @@ -2155,8 +2313,8 @@ private static String names(List list) { } private void checkProxyable(Bean b) { - String what = b.lazy ? "@Lazy" : "@" + (REQUEST.equals(b.scope) ? "Request" : "Session") - + "Scope"; + String what = b.lazy ? "@Lazy" : "@Scope(\"" + (REQUEST.equals(b.scope) ? "request" + : "session") + "\")"; AnnotatedClass where = ctx.lookup(b.type) != null ? b.cls : b.factoryOwnerClass; if (b.cls == null) { ctx.error(where, what + " bean " + b.describe() + " is reached through a class " @@ -2618,6 +2776,9 @@ private void plan() { if (!concerns(cls)) { continue; } + if (testWeaveable != null && !testWeaveable.contains(cls.getInternalName())) { + continue; + } BackendWeaver.Plan plan = null; Aspects a = aspects.get(cls.getInternalName()); String helper = a == null ? null : a.helperBinary.replace('.', '/'); @@ -2628,7 +2789,8 @@ private void plan() { plan.injectFields.put(f.getName(), f.getDescriptor()); } } - boolean stereotyped = isStereotyped(cls); + boolean stereotyped = isStereotyped(cls) || (testConfigurations != null + && testConfigurations.contains(cls.getInternalName())); for (MethodInfo m : cls.getMethods()) { if (m.isSynthetic()) { continue; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java index d4ecbcb57f5..e48456d1908 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java @@ -58,7 +58,7 @@ final class BackendJsonCodecs { static final String JSON_IGNORE = "Lcom/codename1/annotations/JsonIgnore;"; private static final String WRITABLE = "com/codename1/backend/Json$Writable"; private static final String JSON = "com.codename1.backend.Json"; - private static final String CODEC = "com.codename1.backend.JsonCodec"; + private static final String CODEC = "com.codename1.impl.backend.JsonCodec"; private static final String SINK = "com.codename1.backend.ByteSink"; private static final Set PRIMITIVES = new HashSet(Arrays.asList( diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSettings.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSettings.java index 0fd7fd709ba..40b7607d331 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSettings.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSettings.java @@ -29,27 +29,23 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Properties; +import java.util.TreeSet; -/// The settings annotations -- `@ServerConfig`, `@SessionConfig`, -/// `@DataSourceConfig`, `@StaticFilesConfig`, `@EnableManagement`, -/// `@EnableMcpServer` and the exporter settings on `@OpenTelemetry` -- turned -/// into what the generated entry point passes the runtime: key and value pairs -/// for the bottom layer of its configuration, and whether to link the -/// management and MCP endpoints at all. +/// What the generated entry point passes the runtime about its settings: the +/// module's `application.properties`, compiled in as the bottom layer of its +/// configuration, the exporter settings on `@OpenTelemetry`, and whether to link +/// the management and MCP endpoints at all. /// -/// Every attribute is one `cn1.*` key, so an annotation is a compiled-in -/// `application.properties` line and nothing more: the files and the environment -/// still override it, and a key the runtime would refuse is refused here first, -/// where the message can name the class. +/// Compiling the file in is what lets a native binary run with nothing beside +/// it -- a container built FROM SCRATCH has no file next to the binary -- while a +/// file or the environment present at run time still overrides every key, since +/// the compiled layer sits under both. Only the base file is compiled: a profile +/// file describes a different deployment, and baking `application-dev.properties` +/// into a production binary would make the dev settings its defaults. final class BackendSettings { - private static final String PKG = "Lcom/codename1/backend/annotations/"; - static final String SERVER = PKG + "ServerConfig;"; - static final String SESSION = PKG + "SessionConfig;"; - static final String DATA_SOURCE = PKG + "DataSourceConfig;"; - static final String STATIC_FILES = PKG + "StaticFilesConfig;"; - static final String ENABLE_MANAGEMENT = PKG + "EnableManagement;"; - static final String ENABLE_MCP = PKG + "EnableMcpServer;"; - + /// The owner recorded for a key the properties file set. + private static final String FILE = "application.properties"; /// Key and value pairs, in the order they were found. final Map values = new LinkedHashMap(); /// Which class set each key, for a conflict's message. @@ -65,10 +61,18 @@ private BackendSettings(ProcessorContext ctx) { this.ctx = ctx; } - /// Reads every settings annotation in the module, reporting conflicts and - /// values the runtime would refuse through `ctx`. + /// Reads the module's `application.properties` and the settings on + /// `@OpenTelemetry`, reporting conflicts through `ctx`. static BackendSettings resolve(ProcessorContext ctx) { BackendSettings out = new BackendSettings(ctx); + Properties file = RestControllerAnnotationProcessor.baseApplicationProperties(ctx); + if (file != null) { + // Sorted, so the generated entry point is the same on every build. + for (String key : new TreeSet(file.stringPropertyNames())) { + out.values.put(key, file.getProperty(key)); + out.owners.put(key, FILE); + } + } for (AnnotatedClass cls : ctx.getClassIndex().values()) { // The orphan rule every other annotation gets: a class whose source // was deleted left its .class behind and must not keep a setting. @@ -80,8 +84,13 @@ static BackendSettings resolve(ProcessorContext ctx) { } out.management |= RestControllerAnnotationProcessor.applicationPropertyTrue(ctx, "cn1.management.enabled"); - out.mcp |= RestControllerAnnotationProcessor.applicationPropertyTrue(ctx, - "cn1.mcp.enabled"); + // Moving the endpoint or naming its origins is asking for it; the + // endpoint's own default -- on when the server has a tool to serve -- + // still decides whether it answers. + out.mcp |= RestControllerAnnotationProcessor.applicationPropertyTrue(ctx, "cn1.mcp.enabled") + || RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, "cn1.mcp.path") + || RestControllerAnnotationProcessor.applicationPropertyKnown(ctx, + "cn1.mcp.allowedOrigins"); return out; } @@ -96,67 +105,7 @@ List flat() { } private void read(AnnotatedClass cls) { - AnnotationValues a = cls.getClassAnnotation(SERVER); - if (a != null) { - port(cls, a); - positive(cls, a, "workers", "cn1.server.workers"); - positive(cls, a, "backlog", "cn1.server.backlog"); - nonNegative(cls, a, "shutdownTimeoutMillis", "cn1.server.shutdownTimeoutMillis"); - } - a = cls.getClassAnnotation(SESSION); - if (a != null) { - oneOf(cls, a, "store", "cn1.session.store", "memory", "db"); - nonNegative(cls, a, "timeoutSeconds", "cn1.session.timeout"); - string(cls, a, "cookie", "cn1.session.cookie"); - oneOf(cls, a, "sameSite", "cn1.session.same-site", "Lax", "Strict", "None"); - oneOf(cls, a, "secure", "cn1.session.secure", "auto", "true", "false"); - string(cls, a, "namespace", "cn1.session.namespace"); - } - a = cls.getClassAnnotation(DATA_SOURCE); - if (a != null) { - string(cls, a, "url", "cn1.datasource.url"); - positive(cls, a, "poolSize", "cn1.datasource.pool.size"); - nonNegative(cls, a, "borrowTimeoutMillis", "cn1.datasource.pool.borrowTimeoutMillis"); - nonNegative(cls, a, "busyTimeoutMillis", "cn1.datasource.busyTimeoutMillis"); - } - a = cls.getClassAnnotation(STATIC_FILES); - if (a != null) { - string(cls, a, "root", "cn1.static.root"); - path(cls, a, "prefix", "cn1.static.prefix"); - string(cls, a, "index", "cn1.static.index"); - string(cls, a, "cacheControl", "cn1.static.cacheControl"); - } - a = cls.getClassAnnotation(ENABLE_MANAGEMENT); - if (a != null) { - management = true; - set(cls, "cn1.management.enabled", "true"); - path(cls, a, "path", "cn1.management.path"); - } - a = cls.getClassAnnotation(ENABLE_MCP); - if (a != null) { - // Linked, not forced on: the endpoint's own default -- on when the - // server has a tool to serve -- still decides, as without the - // annotation. - mcp = true; - path(cls, a, "path", "cn1.mcp.path"); - List origins = strings(a.get("allowedOrigins")); - if (!origins.isEmpty()) { - StringBuilder joined = new StringBuilder(); - for (String o : origins) { - if (o.indexOf(',') >= 0) { - ctx.error(cls, "@EnableMcpServer allowedOrigins names \"" + o - + "\"; an origin has no comma in it. List each one separately."); - return; - } - if (joined.length() > 0) { - joined.append(','); - } - joined.append(o); - } - set(cls, "cn1.mcp.allowedOrigins", joined.toString()); - } - } - a = cls.getClassAnnotation(RestControllerAnnotationProcessor.OPEN_TELEMETRY); + AnnotationValues a = cls.getClassAnnotation(RestControllerAnnotationProcessor.OPEN_TELEMETRY); if (a != null) { string(cls, a, "endpoint", "cn1.otel.endpoint"); oneOf(cls, a, "protocol", "cn1.otel.protocol", "http/protobuf", "http/json"); @@ -172,19 +121,6 @@ private void string(AnnotatedClass cls, AnnotationValues a, String attribute, St } } - private void path(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { - String v = a.getStringOrDefault(attribute, "").trim(); - if (v.length() == 0) { - return; - } - if (!v.startsWith("/")) { - ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is \"" + v - + "\"; a path starts with /."); - return; - } - set(cls, key, v); - } - private void oneOf(AnnotatedClass cls, AnnotationValues a, String attribute, String key, String... allowed) { String v = a.getStringOrDefault(attribute, "").trim(); @@ -205,47 +141,14 @@ private void oneOf(AnnotatedClass cls, AnnotationValues a, String attribute, Str + list + "."); } - private void port(AnnotatedClass cls, AnnotationValues a) { - int v = a.getIntOrDefault("port", -1); - if (v == -1) { - return; - } - if (v < 0 || v > 65535) { - ctx.error(cls, "@ServerConfig port is " + v + "; a port is 0 to 65535."); - return; - } - set(cls, "cn1.server.port", String.valueOf(v)); - } - - private void positive(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { - int v = a.getIntOrDefault(attribute, -1); - if (v == -1) { - return; - } - if (v < 1) { - ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is " + v - + "; it must be at least 1."); - return; - } - set(cls, key, String.valueOf(v)); - } - - private void nonNegative(AnnotatedClass cls, AnnotationValues a, String attribute, String key) { - int v = a.getIntOrDefault(attribute, -1); - if (v == -1) { - return; - } - if (v < 0) { - ctx.error(cls, "@" + simpleName(a) + " " + attribute + " is " + v - + "; it can't be negative."); - return; - } - set(cls, key, String.valueOf(v)); - } - /// One key, from one class. Two classes giving the same key different values - /// is refused rather than resolved by scan order, which nobody can see. + /// is refused rather than resolved by scan order, which nobody can see. A key + /// `application.properties` already sets keeps the file's value: the file + /// overrides an annotation at run time too, so the build agrees with it. private void set(AnnotatedClass cls, String key, String value) { + if (FILE.equals(owners.get(key))) { + return; + } String existing = values.get(key); if (existing != null && !existing.equals(value)) { ctx.error(cls, key + " is set to \"" + value + "\" here and to \"" + existing @@ -261,16 +164,4 @@ private static String simpleName(AnnotationValues a) { int slash = d.lastIndexOf('/'); return d.substring(slash + 1, d.length() - 1); } - - private static List strings(Object value) { - List out = new ArrayList(); - if (value instanceof List) { - for (Object o : (List) value) { - if (o instanceof String && ((String) o).trim().length() > 0) { - out.add(((String) o).trim()); - } - } - } - return out; - } } diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java index 5e4c2c79dd9..dba933b0d0a 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendSources.java @@ -109,9 +109,8 @@ private void transaction(StringBuilder sb, BackendBeans.Aspect aspect, String in boolean isVoid, String retType) { AnnotationValues tx = aspect.transactional; String propagation = BackendBeans.enumName(tx.get("propagation"), "REQUIRED"); - sb.append(" com.codename1.backend.Transactions.Transaction cn1Tx =\n") - .append(" com.codename1.backend.Transactions.begin(") - .append("com.codename1.backend.Transactions.").append(propagation).append(", ") + sb.append(" Object cn1Tx = com.codename1.impl.backend.BackendAccess.get().begin(") + .append("com.codename1.impl.backend.BackendAccess.").append(propagation).append(", ") .append(tx.getBoolOrDefault("readOnly", false)).append(", ") .append(tx.getIntOrDefault("timeout", -1)).append(");\n"); if (!isVoid) { @@ -121,11 +120,11 @@ private void transaction(StringBuilder sb, BackendBeans.Aspect aspect, String in sb.append(" ").append(isVoid ? "" : "cn1Result = ").append(inner) .append(";\n"); sb.append(" } catch (Throwable cn1Error) {\n"); - sb.append(" com.codename1.backend.Transactions.afterThrow(cn1Tx, ") + sb.append(" com.codename1.impl.backend.BackendAccess.get().afterThrow(cn1Tx, ") .append(rollbackDecision(tx)).append(");\n"); sb.append(" throw cn1Error;\n"); sb.append(" }\n"); - sb.append(" com.codename1.backend.Transactions.commit(cn1Tx);\n"); + sb.append(" com.codename1.impl.backend.BackendAccess.get().commit(cn1Tx);\n"); if (!isVoid) { sb.append(" return cn1Result;\n"); } @@ -276,8 +275,8 @@ private void async(StringBuilder sb, AnnotatedClass cls, BackendBeans.Aspect asp // Looked up on every call, never cached in a static: the executor // belongs to the server the calling thread works for, and two servers // in one process -- or one started again -- each have their own. - String lookup = "com.codename1.backend.Tasks.executor(" + quote(executor) - + ", com.codename1.backend.Tasks." + thread + ")"; + String lookup = "com.codename1.impl.backend.BackendAccess.get().executor(" + + quote(executor) + ", com.codename1.impl.backend.BackendAccess." + thread + ")"; sb.append(" static ").append(retType).append(' ').append(m.getName()).append('(') .append(params).append(") throws Throwable {\n"); sb.append(" ").append(simple(aspect.asyncTaskBinary)).append(" cn1Task = new ") @@ -298,7 +297,7 @@ private String task(AnnotatedClass cls, BackendBeans.Aspect aspect, Type[] args, StringBuilder sb = header(aspect.asyncTaskBinary, "@Async " + cls.getBinaryName() + "." + m.getName()); sb.append("final class ").append(name) - .append(" extends com.codename1.backend.AsyncTask {\n"); + .append(" extends com.codename1.impl.backend.AsyncTask {\n"); if (!isStatic) { sb.append(" private final ").append(owner).append(" self;\n"); } @@ -345,9 +344,9 @@ String proxy(BackendBeans.Bean b) { + " bean " + b.name); sb.append("public final class ").append(name).append(" extends ").append(type) .append(" {\n"); - sb.append(" private final com.codename1.backend.Wiring.Scope cn1Scope;\n\n"); + sb.append(" private final com.codename1.impl.backend.Wiring.Scope cn1Scope;\n\n"); sb.append(" public ").append(name) - .append("(com.codename1.backend.Wiring.Scope scope) {\n"); + .append("(com.codename1.impl.backend.Wiring.Scope scope) {\n"); sb.append(" super();\n this.cn1Scope = scope;\n }\n\n"); sb.append(" private ").append(type).append(" cn1Target() {\n"); sb.append(" return (").append(type).append(") cn1Scope.get(").append(b.slot) @@ -415,7 +414,7 @@ String tool(BackendBeans.Bean b, BackendBeans.Tool t) { StringBuilder sb = header(t.adapterBinary, "@McpTool " + b.cls.getBinaryName() + "." + t.method.getName()); sb.append("public final class ").append(name) - .append(" implements com.codename1.backend.mcp.McpTool {\n"); + .append(" implements com.codename1.impl.backend.mcp.McpTool {\n"); sb.append(" private final ").append(type).append(" target;\n\n"); sb.append(" public ").append(name).append('(').append(type) .append(" target) {\n this.target = target;\n }\n\n"); @@ -431,7 +430,7 @@ String tool(BackendBeans.Bean b, BackendBeans.Tool t) { String schema = t.paramSchema.get(i) != null ? t.paramSchema.get(i) : jsonType(args[i]); sb.append(" properties.put(").append(quote(t.paramNames.get(i))) - .append(", com.codename1.backend.mcp.McpArgs.property(") + .append(", com.codename1.impl.backend.mcp.McpArgs.property(") .append(quote(schema)).append(", ") .append(quote(t.paramDescriptions.get(i))).append(", ") .append(enumConstants(args[i])).append("));\n"); @@ -442,7 +441,7 @@ String tool(BackendBeans.Bean b, BackendBeans.Tool t) { required.append(quote(t.paramNames.get(i))); } } - sb.append(" return com.codename1.backend.mcp.McpArgs.object(properties, ") + sb.append(" return com.codename1.impl.backend.mcp.McpArgs.object(properties, ") .append("new String[] {").append(required).append("});\n }\n\n"); sb.append(" public Object call(java.util.Map arguments) throws Exception {\n"); for (int i = 0; i < args.length; i++) { @@ -505,7 +504,7 @@ String tool(BackendBeans.Bean b, BackendBeans.Tool t) { /// The expression reading one argument out of a map of arguments, converted. String convert(Type t, String map, String name, boolean required) { String args = map + ", " + quote(name) + ", " + required; - String helper = "com.codename1.backend.mcp.McpArgs."; + String helper = "com.codename1.impl.backend.mcp.McpArgs."; switch (t.getSort()) { case Type.BOOLEAN: return helper + "booleanValue(" + args + ")"; case Type.CHAR: return helper + "charValue(" + args + ")"; @@ -630,7 +629,7 @@ String managed(BackendBeans.Bean b) { String type = b.cls.getSourceName(); StringBuilder sb = header(mg.adapterBinary, "@ManagedResource " + b.cls.getBinaryName()); sb.append("public final class ").append(name) - .append(" implements com.codename1.backend.ManagedBean {\n"); + .append(" implements com.codename1.impl.backend.ManagedBean {\n"); sb.append(" private final ").append(type).append(" target;\n\n"); sb.append(" public ").append(name).append('(').append(type) .append(" target) {\n this.target = target;\n }\n\n"); @@ -704,7 +703,7 @@ String managed(BackendBeans.Bean b) { sb.append(" default: throw new IllegalArgumentException(\"No operation \" " + "+ index);\n }\n }\n\n"); sb.append(" /** Publishes the numeric attributes as gauges. */\n"); - sb.append(" public void registerGauges(com.codename1.backend.Backend.Environment " + sb.append(" public void registerGauges(com.codename1.impl.backend.WiringEnvironment " + "environment) {\n"); sb.append(" final ").append(type).append(" bean = target;\n"); for (int i = 0; i < mg.attributes.size(); i++) { @@ -745,7 +744,7 @@ static String gaugeRead(Type t, String call) { if (n.startsWith("java/lang/") && (n.endsWith("Integer") || n.endsWith("Long") || n.endsWith("Double") || n.endsWith("Float") || n.endsWith("Short") || n.endsWith("Byte"))) { - return "com.codename1.backend.mcp.McpArgs.toDouble(" + call + ")"; + return "com.codename1.impl.backend.mcp.McpArgs.toDouble(" + call + ")"; } return null; default: diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java new file mode 100644 index 00000000000..eb1fd155a59 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java @@ -0,0 +1,1076 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.objectweb.asm.Type; + +/// The test pass: what makes a `@BackendTest` class runnable without reflection. +/// +/// For each test class it writes, next to the test classes: +/// +/// - **`Cn1TestContext`**, in the test's package, implementing +/// `com.codename1.impl.backend.test.TestContext`: the settings to start with and +/// the straight-line code that fills the test's `@Autowired`, `@LocalServerPort` +/// and `@MockitoBean` fields; +/// - **a test wiring**, in the main build's entry package, shared by the classes +/// whose contexts have the same key: the application's beans, resolved again +/// with the context's `@TestConfiguration` beans added and its mocks in place of +/// what they replace -- the same [BackendWiringWriter] output the application +/// ships with, plus a lookup by name for the injections above. +/// +/// It also rewrites the test classes the way the main build rewrites beans, so a +/// private field gets a public setter instead of being reached by reflection. +/// +/// For a compiled run it writes `Cn1TestRunner` per test class -- the +/// lifecycle and test calls JUnit would make, in the test's package so it may call +/// package-private methods -- and `cn1app.BackendTestMain` to run them all. +/// +/// The application's classes, stand-ins and adapters are the main build's and are +/// not regenerated; [BackendBeans#forTests] keeps every name it computes equal to +/// the one the main build used. +final class BackendTestGenerator { + static final String BACKEND_TEST = "Lcom/codename1/backend/test/BackendTest;"; + static final String TEST_CONFIGURATION = "Lcom/codename1/backend/test/TestConfiguration;"; + static final String MOCKITO_BEAN = "Lcom/codename1/backend/test/MockitoBean;"; + static final String LOCAL_SERVER_PORT = "Lcom/codename1/backend/test/LocalServerPort;"; + + static final String JUNIT_TEST = "Lorg/junit/jupiter/api/Test;"; + static final String JUNIT_BEFORE_EACH = "Lorg/junit/jupiter/api/BeforeEach;"; + static final String JUNIT_AFTER_EACH = "Lorg/junit/jupiter/api/AfterEach;"; + static final String JUNIT_BEFORE_ALL = "Lorg/junit/jupiter/api/BeforeAll;"; + static final String JUNIT_AFTER_ALL = "Lorg/junit/jupiter/api/AfterAll;"; + static final String JUNIT_DISABLED = "Lorg/junit/jupiter/api/Disabled;"; + static final String JUNIT_NESTED = "Lorg/junit/jupiter/api/Nested;"; + + /// The compiled run's entry point. + static final String MAIN_CLASS = "cn1app.BackendTestMain"; + + private static final String[] WEB_ENVIRONMENTS = {"MOCK", "RANDOM_PORT", "DEFINED_PORT", "NONE"}; + + private final ProcessorContext ctx; + private final Set mainClasses; + private final Set testClasses; + private final File mainOut; + private final boolean compiled; + private final Map sources = new TreeMap(); + + /// What the main build recorded about its wiring. + private String entryPackage; + private final List routers = new ArrayList(); + private final Map sockets = new LinkedHashMap(); + private final List routes = new ArrayList(); + + /// The runner of each compiled test class, for the main. + private final List runners = new ArrayList(); + /// Test classes a compiled run skips, and why. + private final Map skippedClasses = new TreeMap(); + /// Helper class -> method key -> source, for [#call]. + private final Map> accessHelpers = new TreeMap>(); + + /// One test class's configuration. + private static final class Spec { + AnnotatedClass test; + final Set configurations = new TreeSet(); + final List properties = new ArrayList(); + String profile = "test"; + int webEnvironment; + /// {field, internal type} + final List mocks = new ArrayList(); + String key; + /// The wiring this class's context starts; set once its key's beans are resolved. + String wiringBinary; + BackendBeans model; + } + + private BackendTestGenerator(ProcessorContext ctx, Set mainClasses, + Set testClasses, File mainOut, boolean compiled, + java.util.function.Predicate selection) { + this.ctx = ctx; + this.mainClasses = mainClasses; + this.testClasses = testClasses; + this.mainOut = mainOut; + this.compiled = compiled; + this.selection = selection; + } + + /// Which test classes, by binary name, the compiled run runs: the ones the JVM + /// run discovers, so the two runs exercise one set. Asked `Class#method` too, + /// for each test of a selected class. Null runs every class. + private final java.util.function.Predicate selection; + + /// Runs the test pass over `ctx`, whose class index holds both the + /// application's classes and the test classes, and whose output directory is + /// where the test classes are. Errors go to `ctx`. + /// + /// @return the number of test classes it generated a context or runner for + static int generate(ProcessorContext ctx, Set mainClasses, Set testClasses, + File mainOut, boolean compiled) throws ProcessingException { + return generate(ctx, mainClasses, testClasses, mainOut, compiled, null); + } + + /// As above, with the compiled run limited to the test classes `selection` accepts. + static int generate(ProcessorContext ctx, Set mainClasses, Set testClasses, + File mainOut, boolean compiled, java.util.function.Predicate selection) + throws ProcessingException { + BackendTestGenerator g = new BackendTestGenerator(ctx, mainClasses, testClasses, mainOut, + compiled, selection); + return g.run(); + } + + private int run() throws ProcessingException { + List specs = new ArrayList(); + for (String name : new TreeSet(testClasses)) { + AnnotatedClass cls = ctx.lookup(name); + if (cls != null && !cls.isAbstract() && backendTestOf(cls) != null) { + Spec spec = spec(cls); + if (spec != null) { + specs.add(spec); + } + } + } + if (ctx.hasErrors()) { + return 0; + } + if (!specs.isEmpty()) { + readWiringRecord(); + resolveContexts(specs); + if (ctx.hasErrors()) { + return 0; + } + for (Spec spec : specs) { + String name = contextBinary(spec.test.getBinaryName()); + if (claim(name, spec.test)) { + sources.put(name, context(spec)); + } + } + if (ctx.hasErrors()) { + return 0; + } + } + int count = specs.size(); + if (compiled) { + Map byTest = new LinkedHashMap(); + for (Spec spec : specs) { + byTest.put(spec.test.getInternalName(), spec); + } + for (String name : new TreeSet(testClasses)) { + AnnotatedClass cls = ctx.lookup(name); + if (cls == null || (selection != null && !selection.test(cls.getBinaryName()))) { + // Not a class the JVM run discovers -- a helper with an @Test + // method, an IntegrationSpec the includes leave out -- so not one + // the compiled run runs either. + continue; + } + if (runner(cls, byTest.get(name))) { + count++; + } + } + accessHelperSources(); + sources.put(MAIN_CLASS, mainSource()); + } + if (ctx.hasErrors() || sources.isEmpty()) { + return 0; + } + compile(); + return count; + } + + // ---------------------------------------------------------------- specs + + private Spec spec(AnnotatedClass cls) { + Spec spec = new Spec(); + spec.test = cls; + AnnotatedClass declaring = backendTestOf(cls); + AnnotationValues a = declaring.getClassAnnotation(BACKEND_TEST); + Object env = a.get("webEnvironment"); + String envName = BackendBeans.enumName(env, "MOCK"); + for (int i = 0; i < WEB_ENVIRONMENTS.length; i++) { + if (WEB_ENVIRONMENTS[i].equals(envName)) { + spec.webEnvironment = i; + } + } + spec.profile = a.getStringOrDefault("profile", "test").trim(); + Object props = a.get("properties"); + if (props instanceof List) { + for (Object o : (List) props) { + String p = String.valueOf(o); + int eq = p.indexOf('='); + if (eq <= 0) { + ctx.error(cls, "@BackendTest property \"" + p + "\" is not key=value."); + return null; + } + spec.properties.add(p.substring(0, eq).trim()); + spec.properties.add(p.substring(eq + 1).trim()); + } + } + Object listed = a.get("classes"); + if (listed instanceof List) { + for (Object o : (List) listed) { + if (o instanceof Type) { + String internal = ((Type) o).getInternalName(); + AnnotatedClass config = ctx.lookup(internal); + if (config == null || config.getClassAnnotation(TEST_CONFIGURATION) == null) { + ctx.error(cls, "@BackendTest(classes) lists " + internal.replace('/', '.') + + ", which is not a @TestConfiguration of this module's tests."); + return null; + } + spec.configurations.add(internal); + } + } + } + // Static nested @TestConfiguration classes apply to their test, as in Spring + // -- the class's own, and those of the base class that carries @BackendTest. + for (AnnotatedClass owner : declaring == cls + ? Collections.singletonList(cls) : java.util.Arrays.asList(cls, declaring)) { + String prefix = owner.getInternalName() + "$"; + for (String name : new TreeSet(testClasses)) { + AnnotatedClass nested = ctx.lookup(name); + // Directly nested only, as Spring: OuterTest$Nested$Config is the + // nested test's configuration, not the outer test's. + if (name.startsWith(prefix) && name.indexOf('$', prefix.length()) < 0 + && nested != null + && nested.getClassAnnotation(TEST_CONFIGURATION) != null + && !spec.configurations.contains(name)) { + spec.configurations.add(name); + } + } + } + for (AnnotatedClass c : hierarchy(cls)) { + for (FieldInfo f : c.getFields()) { + if (f.getAnnotation(MOCKITO_BEAN) != null) { + Type t = Type.getType(f.getDescriptor()); + if (t.getSort() != Type.OBJECT) { + ctx.error(cls, "@MockitoBean " + f.getName() + " is a " + t.getClassName() + + "; only an object type can be mocked."); + return null; + } + spec.mocks.add(new String[] {f.getName(), t.getInternalName()}); + } + } + } + StringBuilder key = new StringBuilder(); + key.append(spec.configurations).append('|').append(spec.properties).append('|') + .append(spec.profile).append('|').append(spec.webEnvironment).append('|'); + List mockTypes = new ArrayList(); + for (String[] m : spec.mocks) { + mockTypes.add(m[1]); + } + Collections.sort(mockTypes); + key.append(mockTypes); + spec.key = key.toString(); + return spec; + } + + /// The class in `cls`'s hierarchy that carries `@BackendTest` -- `cls` itself or + /// the nearest superclass, since the annotation is inherited -- or null. + private AnnotatedClass backendTestOf(AnnotatedClass cls) { + for (AnnotatedClass c : hierarchy(cls)) { + if (c.getClassAnnotation(BACKEND_TEST) != null) { + return c; + } + } + return null; + } + + /// The class and its superclasses that the build can see, subclass first. + private List hierarchy(AnnotatedClass cls) { + List out = new ArrayList(); + AnnotatedClass c = cls; + while (c != null && out.size() < 32) { + out.add(c); + String sup = c.getSuperInternalName(); + c = sup == null || "java/lang/Object".equals(sup) ? null : ctx.lookup(sup); + } + return out; + } + + // --------------------------------------------------------- wiring record + + private void readWiringRecord() throws ProcessingException { + File record = new File(mainOut, RestControllerAnnotationProcessor.WIRING_RESOURCE); + if (!record.isFile()) { + return; + } + List lines; + try { + lines = Files.readAllLines(record.toPath(), StandardCharsets.UTF_8); + } catch (IOException err) { + throw new ProcessingException("Could not read " + record + ": " + err.getMessage(), err); + } + for (String line : lines) { + String[] f = line.split("\t", -1); + if (f.length >= 2 && "package".equals(f[0])) { + entryPackage = f[1]; + } else if (f.length >= 3 && "router".equals(f[0])) { + routers.add(new BackendWiringWriter.Router(f[1], f[2])); + } else if (f.length >= 3 && "socket".equals(f[0])) { + sockets.put(f[1], f[2]); + } else if (f.length >= 5 && "route".equals(f[0])) { + routes.add(new String[] {f[1], f[2], f[3], f[4]}); + } + } + } + + // -------------------------------------------------------------- contexts + + private void resolveContexts(List specs) throws ProcessingException { + Map> byKey = new LinkedHashMap>(); + for (Spec spec : specs) { + List group = byKey.get(spec.key); + if (group == null) { + group = new ArrayList(); + byKey.put(spec.key, group); + } + group.add(spec); + } + Set wiringNames = new LinkedHashSet(); + for (List group : byKey.values()) { + Spec first = group.get(0); + Set scope = new LinkedHashSet(mainClasses); + scope.addAll(first.configurations); + Set weaveable = new LinkedHashSet(first.configurations); + for (Spec spec : group) { + for (AnnotatedClass c : hierarchy(spec.test)) { + if (testClasses.contains(c.getInternalName())) { + weaveable.add(c.getInternalName()); + } + } + } + BackendBeans model = BackendBeans.forTests(ctx, scope, first.configurations, + first.mocks, weaveable, entryPackage); + if (ctx.hasErrors()) { + return; + } + // The test classes' own fields, which the beans pass does not know: + // the port, the mocks and the clients a test asks for. + for (Spec spec : group) { + for (AnnotatedClass c : hierarchy(spec.test)) { + if (!testClasses.contains(c.getInternalName())) { + continue; + } + for (FieldInfo f : c.getFields()) { + if (f.isStatic() || f.isFinal()) { + continue; + } + if (f.getAnnotation(LOCAL_SERVER_PORT) != null + || f.getAnnotation(MOCKITO_BEAN) != null + || f.getAnnotation(BackendBeans.AUTOWIRED) != null) { + BackendWeaver.Plan plan = model.plans.get(c.getInternalName()); + if (plan == null) { + plan = new BackendWeaver.Plan(c.getInternalName(), null); + model.plans.put(c.getInternalName(), plan); + } + plan.injectFields.put(f.getName(), f.getDescriptor()); + } + } + } + } + model.weaveTests(ctx.getOutputClassDir(), ctx.getClassIndex()); + String pkg = entryPackage != null ? entryPackage : model.entryPackage == null ? "" + : model.entryPackage; + String base = first.test.getBinaryName(); + base = base.substring(base.lastIndexOf('.') + 1).replace('$', '_') + "Cn1TestWiring"; + String simple = base; + for (int n = 2; !wiringNames.add(simple); n++) { + simple = base + n; + } + String binary = BackendBeans.qualify(pkg, simple); + sources.put(binary, new BackendWiringWriter(model).write(pkg, simple, routers, sockets, + routes, true)); + for (Spec spec : group) { + spec.wiringBinary = binary; + spec.model = model; + } + } + } + + /// `pkg.Name` to `pkg.NameCn1TestContext`, a nested class's `$` written `_`. + /// Generated class -> the test class it was named after, for [#claim]. + private final Map generatedNames = new TreeMap(); + + /// Records that `generated` is named after `test`, or reports a build error + /// when another test class already has that name. `$` becomes `_` in a + /// generated name, so a nested `Outer.Inner` and a top-level `Outer_Inner` in + /// one package would otherwise both write `Outer_InnerCn1TestContext`, and one + /// would silently load the other's context. The JVM extension derives the same + /// name to find a context, so the scheme stays and the collision is refused. + private boolean claim(String generated, AnnotatedClass test) { + String previous = generatedNames.get(generated); + if (previous == null) { + generatedNames.put(generated, test.getBinaryName()); + return true; + } + if (previous.equals(test.getBinaryName())) { + return true; + } + ctx.error(test, test.getSourceName() + " and " + previous.replace('$', '.') + + " would both generate " + generated + "; rename one of the two test classes."); + return false; + } + + static String contextBinary(String testBinary) { + int dot = testBinary.lastIndexOf('.'); + String pkg = dot < 0 ? "" : testBinary.substring(0, dot + 1); + return pkg + testBinary.substring(dot + 1).replace('$', '_') + "Cn1TestContext"; + } + + private String context(Spec spec) { + String binary = contextBinary(spec.test.getBinaryName()); + int dot = binary.lastIndexOf('.'); + String pkg = dot < 0 ? "" : binary.substring(0, dot); + String simple = binary.substring(dot + 1); + String testType = spec.test.getSourceName(); + StringBuilder sb = new StringBuilder(); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated from @BackendTest ").append(spec.test.getBinaryName()) + .append(". Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(simple) + .append(" implements com.codename1.impl.backend.test.TestContext {\n\n"); + sb.append(" public String key() {\n return ").append(quote(spec.key)) + .append(";\n }\n\n"); + BackendSettings settings = BackendSettings.resolve(ctx); + sb.append(" public String[] compiledSettings() {\n return ") + .append(array(settings.flat())).append(";\n }\n\n"); + List profileSettings = new ArrayList(); + Properties profileFile = RestControllerAnnotationProcessor.profileApplicationProperties( + ctx, spec.profile); + if (profileFile != null) { + for (String k : new TreeSet(profileFile.stringPropertyNames())) { + profileSettings.add(k); + profileSettings.add(profileFile.getProperty(k)); + } + } + sb.append(" public String[] profileSettings() {\n return ") + .append(array(profileSettings)).append(";\n }\n\n"); + sb.append(" public String[] properties() {\n return ") + .append(array(spec.properties)).append(";\n }\n\n"); + sb.append(" public String profile() {\n return ").append(quote(spec.profile)) + .append(";\n }\n\n"); + sb.append(" public int webEnvironment() {\n return ").append(spec.webEnvironment) + .append(";\n }\n\n"); + sb.append(" public boolean requiresDataSource() {\n return ") + .append(spec.model.needsDatabase).append(";\n }\n\n"); + sb.append(" public void prepare() {\n"); + if (ctx.lookup(OrmAnnotationProcessor.BACKEND_BOOTSTRAP_BINARY.replace('.', '/')) != null + || RestControllerAnnotationProcessor.resolveClass(ctx, + OrmAnnotationProcessor.BACKEND_BOOTSTRAP_BINARY.replace('.', '/')) != null) { + // What the generated main does first: the daos register here. + sb.append(" new ").append(OrmAnnotationProcessor.BACKEND_BOOTSTRAP_BINARY) + .append("();\n"); + } + sb.append(" }\n\n"); + sb.append(" public com.codename1.impl.backend.BackendApplication createApplication() {\n") + .append(" return new ").append(spec.wiringBinary).append("();\n }\n\n"); + List mockNames = new ArrayList(); + for (BackendBeans.Bean b : spec.model.beans) { + if (b.mockType != null) { + mockNames.add(b.name); + } + } + sb.append(" public String[] mockBeans() {\n return ").append(array(mockNames)) + .append(";\n }\n\n"); + sb.append(" public void inject(Object test, com.codename1.impl.backend.test.TestEnvironment " + + "environment) throws Exception {\n"); + sb.append(" if (!(test instanceof ").append(testType).append(")) {\n") + .append(" throw new IllegalArgumentException(\"Not a ").append(testType) + .append(": \" + test);\n }\n"); + sb.append(" ").append(testType).append(" t = (").append(testType).append(") test;\n"); + Set injectedNames = new LinkedHashSet(); + for (AnnotatedClass c : hierarchy(spec.test)) { + for (FieldInfo f : c.getFields()) { + if (f.isStatic() || f.isFinal()) { + continue; + } + String value = injection(spec, c, f); + if (value == null) { + continue; + } + if (!injectedNames.add(f.getName())) { + // The rule the main build applies to beans, for the same reason: + // a field is injected through a public setter named after it, so + // two of one name in a hierarchy are one virtual method -- the + // subclass's field was set twice and the superclass's never. + ctx.error(c, c.getSourceName() + "." + f.getName() + " has the name of " + + "another injected field of " + spec.test.getSourceName() + + "'s class hierarchy, and the build injects a field through a " + + "setter named after it. Rename one."); + continue; + } + sb.append(" t.").append(BackendWeaver.INJECT_PREFIX).append(f.getName()) + .append('(').append(value).append(");\n"); + } + } + sb.append(" }\n}\n"); + return sb.toString(); + } + + /// The expression a test field is filled with, or null when it is not injected. + private String injection(Spec spec, AnnotatedClass owner, FieldInfo f) { + Type t = Type.getType(f.getDescriptor()); + String where = owner.getSourceName() + "." + f.getName(); + if (f.getAnnotation(LOCAL_SERVER_PORT) != null) { + if (t.getSort() != Type.INT) { + ctx.error(owner, "@LocalServerPort " + where + " is a " + t.getClassName() + + "; a port is an int."); + return null; + } + return "environment.port()"; + } + boolean mock = f.getAnnotation(MOCKITO_BEAN) != null; + AnnotationValues autowired = f.getAnnotation(BackendBeans.AUTOWIRED); + if (!mock && autowired == null) { + return null; + } + if (t.getSort() != Type.OBJECT) { + ctx.error(owner, where + " is a " + t.getClassName() + ", which no bean is."); + return null; + } + String type = t.getInternalName(); + String source = t.getClassName().replace('$', '.'); + if (!mock) { + if ("com/codename1/backend/test/MockMvc".equals(type)) { + return "environment.mockMvc()"; + } + if ("com/codename1/backend/test/TestRestTemplate".equals(type)) { + return "environment.restTemplate()"; + } + if ("com/codename1/backend/Backend".equals(type)) { + return "environment.backend()"; + } + if (BackendBeans.CONFIG_TYPE.equals(type)) { + return "environment.backend().getConfig()"; + } + if (BackendBeans.DATASOURCE_TYPE.equals(type)) { + return "environment.backend().getDataSource()"; + } + if (BackendBeans.ENTITIES_TYPE.equals(type)) { + return "environment.backend().getEntityManager()"; + } + } + AnnotationValues qualifier = f.getAnnotation(BackendBeans.QUALIFIER); + String wanted = qualifier == null ? null : qualifier.getStringOrDefault("value", ""); + List candidates = new ArrayList(); + for (BackendBeans.Bean b : spec.model.beans) { + if (b.types.contains(type) && (!mock || b.mockType != null) + && (wanted == null || wanted.length() == 0 || wanted.equals(b.name))) { + candidates.add(b); + } + } + BackendBeans.Bean chosen = null; + if (candidates.size() == 1) { + chosen = candidates.get(0); + } else { + for (BackendBeans.Bean b : candidates) { + if (b.primary) { + chosen = chosen == null ? b : null; + } + } + } + if (chosen == null) { + boolean required = autowired == null || autowired.getBoolOrDefault("required", true); + if (candidates.isEmpty() && !required) { + return null; + } + ctx.error(owner, where + " needs a " + source + ", and " + + (candidates.isEmpty() ? "no bean of this test's application has that type." + : candidates.size() + " beans could fill it; name one with @Qualifier or mark " + + "one @Primary.")); + return null; + } + return "(" + source + ") environment.bean(" + quote(chosen.name) + ")"; + } + + // --------------------------------------------------------------- runners + + /// Writes the compiled runner of one test class; false when it has no tests. + private boolean runner(AnnotatedClass cls, Spec spec) { + if (cls.isAbstract() || cls.isInterface() || cls.isSynthetic()) { + return false; + } + List chain = methodHierarchy(cls); + List tests = new ArrayList(); + Overrides seen = new Overrides(); + java.util.IdentityHashMap owners = + new java.util.IdentityHashMap(); + for (AnnotatedClass c : chain) { + for (MethodInfo m : c.getMethods()) { + if (carries(m, JUNIT_TEST)) { + // -Dtest=Class#method narrows the JVM run to that method; the + // compiled run runs the same ones. + if (!seen.covers(c, m) && (selection == null + || selection.test(cls.getBinaryName() + "#" + m.getName()))) { + tests.add(m); + owners.put(m, c); + } + } + // An override without @Test is not a test, and it hides the + // superclass's: JUnit runs neither. Collected anyway, the + // generated call would dispatch to the override. + seen.declare(c, m); + } + } + if (tests.isEmpty()) { + return false; + } + String testType = cls.getSourceName(); + String binary = cls.getBinaryName(); + String runnerBinary = binary.replace('$', '_') + "Cn1TestRunner"; + if (!claim(runnerBinary, cls)) { + return false; + } + int dot = runnerBinary.lastIndexOf('.'); + String pkg = dot < 0 ? "" : runnerBinary.substring(0, dot); + String simple = runnerBinary.substring(dot + 1); + String skip = null; + AnnotationValues classDisabled = annotation(cls.getClassAnnotations(), JUNIT_DISABLED); + if (classDisabled != null) { + skip = "disabled: " + classDisabled.getStringOrDefault("value", ""); + } else if (cls.getClassAnnotation(JUNIT_NESTED) != null || isInner(cls)) { + skip = "a @Nested or inner test class does not run in a compiled test"; + } else if (spec != null && !spec.mocks.isEmpty()) { + skip = "@MockitoBean needs Mockito, which runs only on the JVM"; + } else if (!hasNoArgConstructor(cls)) { + skip = "the class has no constructor without arguments"; + } + StringBuilder sb = new StringBuilder(); + if (pkg.length() > 0) { + sb.append("package ").append(pkg).append(";\n\n"); + } + sb.append("// Generated from the tests of ").append(binary).append(". Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(simple).append(" {\n"); + sb.append(" private static final String CLS = ").append(quote(binary)).append(";\n\n"); + sb.append(" private ").append(simple).append("() {\n }\n\n"); + sb.append(" public static void run() {\n"); + if (skip != null) { + for (MethodInfo m : tests) { + sb.append(" com.codename1.impl.backend.test.TestRun.skipped(CLS, ") + .append(quote(m.getName())).append(", ").append(quote(skip)).append(");\n"); + } + sb.append(" }\n}\n"); + sources.put(runnerBinary, sb.toString()); + skippedClasses.put(binary, skip); + runners.add(runnerBinary); + return true; + } + List beforeAll = lifecycle(chain, JUNIT_BEFORE_ALL, true, true, owners); + List afterAll = lifecycle(chain, JUNIT_AFTER_ALL, true, false, owners); + sb.append(" boolean ready = true;\n"); + sb.append(" String notReady = \"@BeforeAll failed\";\n"); + for (MethodInfo m : beforeAll) { + // An assumption that does not hold aborts the class, as JUnit aborts the + // container: its tests are skipped, not failed, so an environment-gated + // class has the same result in both runs. + sb.append(" if (ready) {\n try {\n ") + .append(call(owners.get(m), m, pkg, testType)).append(";\n") + .append(" } catch (Throwable err) {\n") + .append(" if (com.codename1.impl.backend.test.TestRun.isAbort(err)) {\n") + .append(" notReady = ").append(quote("@BeforeAll " + m.getName() + " aborted: ")) + .append(" + err.getMessage();\n") + .append(" } else {\n") + .append(" com.codename1.impl.backend.test.TestRun.lifecycleFailed(CLS, ") + .append(quote("@BeforeAll " + m.getName())).append(", err);\n") + .append(" }\n") + .append(" ready = false;\n }\n }\n"); + } + int index = 0; + for (MethodInfo m : tests) { + // Composed too, as JUnit finds it: @Fast @Disabled-meta skips on both runs. + AnnotationValues disabled = annotation(m.getAnnotations(), JUNIT_DISABLED); + if (disabled != null) { + sb.append(" com.codename1.impl.backend.test.TestRun.skipped(CLS, ") + .append(quote(m.getName())).append(", ") + .append(quote("disabled: " + disabled.getStringOrDefault("value", ""))) + .append(");\n"); + } else { + sb.append(" if (ready) {\n t").append(index).append("();\n") + .append(" } else {\n") + .append(" com.codename1.impl.backend.test.TestRun.skipped(CLS, ") + .append(quote(m.getName())).append(", notReady);\n }\n"); + } + index++; + } + for (MethodInfo m : afterAll) { + sb.append(" try {\n ").append(call(owners.get(m), m, pkg, testType)) + .append(";\n } catch (Throwable err) {\n") + .append(" com.codename1.impl.backend.test.TestRun.lifecycleFailed(CLS, ") + .append(quote("@AfterAll " + m.getName())).append(", err);\n }\n"); + } + sb.append(" }\n\n"); + List beforeEach = lifecycle(chain, JUNIT_BEFORE_EACH, false, true, owners); + List afterEach = lifecycle(chain, JUNIT_AFTER_EACH, false, false, owners); + index = 0; + for (MethodInfo m : tests) { + checkCallable(cls, m, false); + sb.append(" private static void t").append(index++).append("() {\n"); + sb.append(" long started = com.codename1.impl.backend.test.TestRun.started();\n"); + sb.append(" ").append(testType).append(" test = null;\n"); + sb.append(" Throwable failure = null;\n"); + sb.append(" try {\n"); + sb.append(" test = new ").append(testType).append("();\n"); + if (spec != null) { + sb.append(" ").append(contextBinary(binary)).append(" context = new ") + .append(contextBinary(binary)).append("();\n"); + sb.append(" context.inject(test, com.codename1.impl.backend.test.TestContexts" + + ".acquire(context));\n"); + } + for (MethodInfo b : beforeEach) { + sb.append(" ").append(call(owners.get(b), b, pkg, "test")).append(";\n"); + } + sb.append(" ").append(call(owners.get(m), m, pkg, "test")).append(";\n"); + sb.append(" } catch (Throwable err) {\n failure = err;\n }\n"); + if (!afterEach.isEmpty()) { + sb.append(" if (test != null) {\n"); + for (MethodInfo a : afterEach) { + sb.append(" try {\n ").append(call(owners.get(a), a, pkg, "test")) + .append(";\n } catch (Throwable err) {\n") + // A real teardown failure fails the test even after an + // assumption aborted it, as JUnit reports it. + .append(" if (failure == null || (com.codename1.impl.backend.test.TestRun" + + ".isAbort(failure) && !com.codename1.impl.backend.test.TestRun.isAbort(err))) {\n") + .append(" failure = err;\n }\n") + .append(" }\n"); + } + sb.append(" }\n"); + } + sb.append(" com.codename1.impl.backend.test.TestRun.finished(CLS, ") + .append(quote(m.getName())).append(", started, failure);\n }\n\n"); + } + sb.append("}\n"); + sources.put(runnerBinary, sb.toString()); + runners.add(runnerBinary); + return true; + } + + /// The lifecycle methods of a kind across the hierarchy: superclass first for + /// a "before", subclass first for an "after", as JUnit orders them. + private List lifecycle(List chain, String annotation, + boolean isStatic, boolean superFirst, + java.util.Map owners) { + List out = new ArrayList(); + Overrides seen = new Overrides(); + for (AnnotatedClass c : chain) { + List level = new ArrayList(); + for (MethodInfo m : c.getMethods()) { + if (!carries(m, annotation)) { + // As for tests: an unannotated override or hiding method is + // what JUnit sees, and it is not a lifecycle method. + seen.declare(c, m); + continue; + } + boolean covered = seen.covers(c, m); + seen.declare(c, m); + if (!covered) { + owners.put(m, c); + if (m.isStatic() != isStatic) { + ctx.error(c, m.getName() + " is " + (isStatic ? "not " : "") + + "static; " + annotation.substring(annotation.lastIndexOf('/') + 1, + annotation.length() - 1) + " methods are " + + (isStatic ? "static." : "instance methods.")); + continue; + } + checkCallable(c, m, isStatic); + level.add(m); + } + } + if (superFirst) { + out.addAll(0, level); + } else { + out.addAll(level); + } + } + return out; + } + + /// Whether `m` carries `annotation` directly or through a composed annotation + /// -- one whose own type is annotated with it, at any depth -- as JUnit finds + /// `@Test` and the lifecycle annotations. A composed annotation the build + /// cannot see (from a library jar rather than this module) is not followed. + private boolean carries(MethodInfo m, String annotation) { + return annotation(m.getAnnotations(), annotation) != null; + } + + /// `annotation` among `annotations`, directly or through a composed annotation + /// -- its values as the composing type declares them -- or null. + private AnnotationValues annotation(java.util.Map annotations, + String annotation) { + AnnotationValues direct = annotations.get(annotation); + if (direct != null) { + return direct; + } + Set seen = new java.util.HashSet(); + for (String descriptor : annotations.keySet()) { + AnnotationValues found = composedOf(descriptor, annotation, seen); + if (found != null) { + return found; + } + } + return null; + } + + private AnnotationValues composedOf(String descriptor, String annotation, Set seen) { + if (!descriptor.startsWith("L") || !descriptor.endsWith(";") || !seen.add(descriptor) + || seen.size() > 16) { + return null; + } + AnnotatedClass type = ctx.lookup(descriptor.substring(1, descriptor.length() - 1)); + if (type == null || !type.isAnnotation()) { + return null; + } + AnnotationValues meta = type.getClassAnnotation(annotation); + if (meta != null) { + return meta; + } + for (String next : type.getClassAnnotations().keySet()) { + AnnotationValues found = composedOf(next, annotation, seen); + if (found != null) { + return found; + } + } + return null; + } + + /// The class, its superclasses and every interface they implement, for the + /// methods JUnit collects: classes subclass first, then the interfaces, whose + /// default `@Test` and lifecycle methods a test class inherits. Interfaces come + /// last because a class's method overrides theirs, and so that a "before" from + /// an interface runs ahead of the class's, as JUnit orders them. + private List methodHierarchy(AnnotatedClass cls) { + List out = new ArrayList(hierarchy(cls)); + Set visited = new LinkedHashSet(); + java.util.ArrayDeque pending = new java.util.ArrayDeque(); + for (AnnotatedClass c : out) { + pending.addAll(c.getInterfaceInternalNames()); + } + List interfaces = new ArrayList(); + while (!pending.isEmpty() && visited.size() < 64) { + String name = pending.removeFirst(); + AnnotatedClass i = visited.add(name) ? ctx.lookup(name) : null; + if (i != null) { + interfaces.add(i); + pending.addAll(i.getInterfaceInternalNames()); + } + } + out.addAll(interfaces); + return out; + } + + /// The methods declared lower in a hierarchy, by signature, and what they + /// override or hide from JUnit when they carry no annotation of their own. + private static final class Overrides { + private final Map> packages = new java.util.HashMap>(); + + /// Records `m`, declared by `owner`: any method but a private one, a + /// constructor or a synthetic bridge can override or hide. + void declare(AnnotatedClass owner, MethodInfo m) { + if (m.isPrivate() || m.isConstructor() || m.isSynthetic()) { + return; + } + String signature = m.getName() + m.getDescriptor(); + List pkgs = packages.get(signature); + if (pkgs == null) { + pkgs = new ArrayList(); + packages.put(signature, pkgs); + } + pkgs.add(packageOf(owner.getBinaryName())); + } + + /// Whether a method recorded so far overrides or hides `m`, declared higher + /// up by `owner`. A public or protected one is covered by any; a + /// package-private one only from its own package -- Java's rule, so a + /// same-signature method in a subclass elsewhere does not override it, and + /// JUnit still runs the base class's test. + boolean covers(AnnotatedClass owner, MethodInfo m) { + List pkgs = packages.get(m.getName() + m.getDescriptor()); + if (pkgs == null || m.isPrivate()) { + return false; + } + if (m.isPublic() || m.isProtected() || owner.isInterface()) { + return true; + } + return pkgs.contains(packageOf(owner.getBinaryName())); + } + } + + /// The call to `m`, declared by `owner`, from a runner in package `runnerPkg`: + /// `target.m()`, or `Type.m()` for a static one. A method that is not public + /// and is declared in another package -- a JUnit base class's package-private + /// or protected test -- cannot be called from there, so it goes through a + /// helper generated in its own package; JUnit reaches it reflectively, which + /// the compiled run cannot. + private String call(AnnotatedClass owner, MethodInfo m, String runnerPkg, String target) { + String ownerPkg = packageOf(owner.getBinaryName()); + if (m.isPublic() || ownerPkg.equals(runnerPkg)) { + return (m.isStatic() ? owner.getSourceName() : target) + "." + m.getName() + "()"; + } + String helper = (ownerPkg.length() == 0 ? "" : ownerPkg + ".") + + owner.getBinaryName().substring(owner.getBinaryName().lastIndexOf('.') + 1) + .replace('$', '_') + "Cn1TestAccess"; + java.util.Map calls = accessHelpers.get(helper); + if (calls == null) { + calls = new TreeMap(); + accessHelpers.put(helper, calls); + } + String body = m.isStatic() + ? " public static void " + m.getName() + "() throws Throwable {\n " + + owner.getSourceName() + "." + m.getName() + "();\n }\n" + : " public static void " + m.getName() + "(" + owner.getSourceName() + + " target) throws Throwable {\n target." + m.getName() + "();\n }\n"; + calls.put(m.getName() + (m.isStatic() ? "/static" : ""), body); + return helper + "." + m.getName() + "(" + (m.isStatic() ? "" : target) + ")"; + } + + private static String packageOf(String binaryName) { + int dot = binaryName.lastIndexOf('.'); + return dot < 0 ? "" : binaryName.substring(0, dot); + } + + /// The access helpers [#call] asked for, as their sources. + private void accessHelperSources() { + for (java.util.Map.Entry> e : accessHelpers.entrySet()) { + String binary = e.getKey(); + int dot = binary.lastIndexOf('.'); + StringBuilder sb = new StringBuilder(); + if (dot > 0) { + sb.append("package ").append(binary, 0, dot).append(";\n\n"); + } + sb.append("// Calls the compiled test runners cannot make from their own package. Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class ").append(binary.substring(dot + 1)).append(" {\n"); + sb.append(" private ").append(binary.substring(dot + 1)).append("() {\n }\n\n"); + for (String body : e.getValue().values()) { + sb.append(body).append('\n'); + } + sb.append("}\n"); + sources.put(binary, sb.toString()); + } + } + + /// A test or lifecycle method the runner can call: not private, taking nothing. + private void checkCallable(AnnotatedClass owner, MethodInfo m, boolean isStatic) { + if (m.isPrivate()) { + ctx.error(owner, owner.getSourceName() + "." + m.getName() + " is private; a test " + + "method is package-private or public, as JUnit requires."); + } + if (!m.getDescriptor().startsWith("()")) { + ctx.error(owner, owner.getSourceName() + "." + m.getName() + " takes parameters, " + + "which a compiled test cannot supply."); + } else if (!m.getDescriptor().endsWith("V")) { + // JUnit does not run a test or lifecycle method that returns a value; the + // runner would call it and report a pass the JVM run never gave. + ctx.error(owner, owner.getSourceName() + "." + m.getName() + " returns a value; a " + + "test or lifecycle method is void, as JUnit requires."); + } + } + + private boolean isInner(AnnotatedClass cls) { + for (FieldInfo f : cls.getFields()) { + if (f.getName().startsWith("this$")) { + return true; + } + } + return false; + } + + private static boolean hasNoArgConstructor(AnnotatedClass cls) { + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() && "()V".equals(m.getDescriptor()) && !m.isPrivate()) { + return true; + } + } + return false; + } + + private String mainSource() { + StringBuilder sb = new StringBuilder(); + sb.append("package cn1app;\n\n"); + sb.append("// Generated: runs every compiled backend test. Do not edit.\n"); + sb.append("@com.codename1.backend.annotations.Generated\n"); + sb.append("public final class BackendTestMain {\n"); + sb.append(" private BackendTestMain() {\n }\n\n"); + sb.append(" public static void main(String[] args) {\n"); + for (String r : runners) { + sb.append(" ").append(r).append(".run();\n"); + } + sb.append(" com.codename1.impl.backend.test.TestContexts.shutdown();\n"); + sb.append(" int failures = com.codename1.impl.backend.test.TestRun.finish();\n"); + sb.append(" System.exit(failures == 0 ? 0 : 1);\n"); + sb.append(" }\n}\n"); + return sb.toString(); + } + + // -------------------------------------------------------------- helpers + + private void compile() throws ProcessingException { + List cp = new ArrayList(); + cp.add(ctx.getOutputClassDir()); + for (String element : ctx.getCompileClasspath()) { + cp.add(new File(element)); + } + try { + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } catch (IOException err) { + throw new ProcessingException("Could not compile the generated test sources: " + + err.getMessage(), err); + } + ctx.getLog().info("cn1: generated " + sources.size() + " backend test class(es)" + + (skippedClasses.isEmpty() ? "" : "; a compiled run skips " + + skippedClasses.keySet())); + } + + private static String array(List values) { + StringBuilder sb = new StringBuilder("new String[] {"); + for (int i = 0; i < values.size(); i++) { + sb.append(i == 0 ? "" : ", ").append(quote(values.get(i))); + } + return sb.append('}').toString(); + } + + private static String quote(String value) { + return BackendSources.quote(value); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java new file mode 100644 index 00000000000..d44e1196808 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTests.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; +import java.io.File; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import com.codename1.build.Log; + +/// The entry to the backend test pass for the goals: the JVM one +/// (`process-test-annotations`) and the compiled one (`backend-test`), which run +/// it over different trees but the same way. +public final class BackendTests { + /// The entry point a compiled test run is linked with. + public static final String MAIN_CLASS = BackendTestGenerator.MAIN_CLASS; + + private BackendTests() { + } + + /// Scans `mainClasses` and `testClasses` into one index and runs + /// [BackendTestGenerator] over it, writing into `testClasses`. + /// + /// @return how many test classes were prepared + public static int process(File mainClasses, File testClasses, File stubs, File projectDir, + List sourceRoots, String encoding, List classpath, + boolean compiled, Log log) + throws BuildExecutionException, BuildFailureException { + return process(mainClasses, testClasses, stubs, projectDir, sourceRoots, encoding, classpath, + compiled, null, log); + } + + /// As above, with a compiled run limited to the test classes, by binary name, + /// that `selection` accepts -- the ones the build tool's JVM run discovers -- + /// and to the tests of each it accepts as `Class#method`. + public static int process(File mainClasses, File testClasses, File stubs, File projectDir, + List sourceRoots, String encoding, List classpath, + boolean compiled, java.util.function.Predicate selection, Log log) + throws BuildExecutionException, BuildFailureException { + return process(java.util.Collections.singletonList(mainClasses), testClasses, stubs, projectDir, + sourceRoots, encoding, classpath, compiled, selection, log); + } + + /// As above, over every directory the application's classes were compiled into + /// -- a Gradle backend with Kotlin and Java sources has one per language, and + /// its beans may be in either. + public static int process(List mainClassDirs, File testClasses, File stubs, File projectDir, + List sourceRoots, String encoding, List classpath, + boolean compiled, java.util.function.Predicate selection, Log log) + throws BuildExecutionException, BuildFailureException { + return process(mainClassDirs, testClasses, java.util.Collections.emptyList(), stubs, + projectDir, sourceRoots, encoding, classpath, compiled, selection, log); + } + + /// As above, with `otherTestClassDirs` -- the other compiler's output of a mixed + /// Java and Kotlin test set -- read as part of the same tests: a Kotlin test + /// whose `@BackendTest` base or `@TestConfiguration` is Java, or the reverse, + /// is one hierarchy. What the pass generates goes into `testClasses`. + public static int process(List mainClassDirs, File testClasses, List otherTestClassDirs, + File stubs, File projectDir, + List sourceRoots, String encoding, List classpath, + boolean compiled, java.util.function.Predicate selection, Log log) + throws BuildExecutionException, BuildFailureException { + Map main = new LinkedHashMap(); + Map tests; + // The main build's wiring record is in one of them; read from that one. + File mainClasses = mainClassDirs.isEmpty() ? testClasses : mainClassDirs.get(0); + try { + for (File dir : mainClassDirs) { + if (dir.isDirectory()) { + main.putAll(ClassScanner.scan(dir)); + if (new File(dir, RestControllerAnnotationProcessor.WIRING_RESOURCE).isFile()) { + mainClasses = dir; + } + } + } + tests = new LinkedHashMap(); + for (File dir : otherTestClassDirs) { + if (dir != null && dir.isDirectory() && !dir.equals(testClasses)) { + tests.putAll(ClassScanner.scan(dir)); + } + } + tests.putAll(ClassScanner.scan(testClasses)); + } catch (ProcessingException err) { + throw new BuildExecutionException("Could not scan the compiled classes: " + + err.getMessage(), err); + } + if (!compiled && !mentions(tests, BackendTestGenerator.BACKEND_TEST)) { + return 0; + } + Map index = new LinkedHashMap(main); + index.putAll(tests); + Set mainNames = new LinkedHashSet(main.keySet()); + mainNames.removeAll(tests.keySet()); + ProcessorContext ctx = new ProcessorContext(testClasses, stubs, index, log, projectDir, + new Properties(), null, sourceRoots, encoding, classpath); + int count; + try { + count = BackendTestGenerator.generate(ctx, mainNames, tests.keySet(), mainClasses, + compiled, selection); + } catch (ProcessingException err) { + throw new BuildExecutionException(err.getMessage(), err); + } + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("The backend tests could not be prepared:"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) { + sb.append("\n - ").append(e); + } + throw new BuildFailureException(sb.toString()); + } + return count; + } + + private static boolean mentions(Map classes, String descriptor) { + for (AnnotatedClass cls : classes.values()) { + if (cls.getAllAnnotationDescriptors().contains(descriptor)) { + return true; + } + } + return false; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java index eba9d0baa3d..32cf5799a14 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWeaver.java @@ -138,10 +138,15 @@ static String bridge(String method) { /// Rewrites one class file in place. Answers false when it was already /// rewritten, or has nothing to rewrite. static boolean weave(File outputDir, Plan plan) throws IOException { + return weaveFile(new File(outputDir, plan.internalName + ".class"), plan); + } + + /// As [#weave], for the class file at `file` -- a test class that lives in + /// another output directory than the one the pass writes into. + static boolean weaveFile(File file, Plan plan) throws IOException { if (plan.isEmpty()) { return false; } - File file = new File(outputDir, plan.internalName + ".class"); if (!file.isFile()) { throw new IOException("Cannot weave " + plan.internalName + ": no class file at " + file); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java index a0b088995dc..2c66ed07bf5 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java @@ -77,14 +77,27 @@ static final class Router { /// the listing String write(String pkg, List routers, Map sockets, List routes) { + return write(pkg, CLASS_NAME, routers, sockets, routes, false); + } + + /// The class being written; [#CLASS_NAME] unless a test wiring is. + private String className = CLASS_NAME; + + /// The same, under another name, and for a test also implementing + /// `com.codename1.impl.backend.test.TestBeans` so the test's fields can be + /// filled by name. The production wiring never has that lookup. + String write(String pkg, String name, List routers, Map sockets, + List routes, boolean testBeans) { + className = name; StringBuilder sb = new StringBuilder(); if (pkg.length() > 0) { sb.append("package ").append(pkg).append(";\n\n"); } sb.append("// Generated from the beans of this module. Do not edit.\n"); sb.append("@com.codename1.backend.annotations.Generated\n"); - sb.append("public final class ").append(CLASS_NAME) - .append(" implements com.codename1.backend.Backend.Application {\n"); + sb.append("public final class ").append(className) + .append(" implements com.codename1.impl.backend.BackendApplication") + .append(testBeans ? ", com.codename1.impl.backend.test.TestBeans" : "").append(" {\n"); fields(sb); create(sb, routers); webSockets(sb, sockets); @@ -96,24 +109,38 @@ String write(String pkg, List routers, Map sockets, sb.append(" public boolean tracksCurrentRequest() {\n return ") .append(model.requestSlots + model.sessionSlots > 0).append(";\n }\n\n"); requestEnded(sb); - sb.append(" public com.codename1.backend.Scheduler getScheduler() {\n") + sb.append(" public com.codename1.impl.backend.Scheduler getScheduler() {\n") .append(" return scheduler;\n }\n\n"); describeBeans(sb); describeRoutes(sb, routes); scopes(sb); prototypes(sb); + if (testBeans) { + testBeans(sb); + } sb.append("}\n"); return sb.toString(); } + /// `bean(name)` over the fields, for a test's injections. A prototype is built + /// anew, as an injection point would get it. + private void testBeans(StringBuilder sb) { + sb.append(" public Object bean(String name) {\n"); + for (BackendBeans.Bean b : model.beans) { + sb.append(" if (").append(BackendSources.quote(b.name)) + .append(".equals(name)) {\n return ").append(reference(b)).append(";\n }\n"); + } + sb.append(" return null;\n }\n\n"); + } + // ----------------------------------------------------------------- fields private void fields(StringBuilder sb) { sb.append(" private com.codename1.backend.Config config;\n"); sb.append(" private com.codename1.backend.DataSource dataSource;\n"); sb.append(" private com.codename1.backend.orm.EntityManager entities;\n"); - sb.append(" private com.codename1.backend.orm.TransactionSession transactionSession;\n"); - sb.append(" private com.codename1.backend.Scheduler scheduler;\n"); + sb.append(" private com.codename1.impl.backend.TransactionSession transactionSession;\n"); + sb.append(" private com.codename1.impl.backend.Scheduler scheduler;\n"); // The controllers and endpoints this start registered, by binary name, // for describeRoutes(): one whose condition is off serves nothing, and // listing its routes anyway sent an agent to endpoints that answer 404. @@ -137,12 +164,12 @@ private void fields(StringBuilder sb) { if (model.lazySlots > 0) { scopeField(sb, "lazyScope", "lazyBean"); } - sb.append("\n public ").append(CLASS_NAME).append("() {\n }\n\n"); + sb.append("\n public ").append(className).append("() {\n }\n\n"); } private static void scopeField(StringBuilder sb, String field, String method) { - sb.append(" private final com.codename1.backend.Wiring.Scope ").append(field) - .append(" = new com.codename1.backend.Wiring.Scope() {\n") + sb.append(" private final com.codename1.impl.backend.Wiring.Scope ").append(field) + .append(" = new com.codename1.impl.backend.Wiring.Scope() {\n") .append(" public Object get(int slot) {\n") .append(" return ").append(method).append("(slot);\n") .append(" }\n };\n"); @@ -152,7 +179,7 @@ private static void scopeField(StringBuilder sb, String field, String method) { private void create(StringBuilder sb, List routers) { sb.append(" public com.codename1.backend.HttpServer.Handler[] create(\n") - .append(" com.codename1.backend.Backend.Environment environment) " + .append(" com.codename1.impl.backend.WiringEnvironment environment) " + "throws Exception {\n"); // Every field back to null first. A builder started again reuses this // object, and a bean whose condition is off this time -- or a lazy one @@ -175,7 +202,7 @@ private void create(StringBuilder sb, List routers) { sb.append(" dataSource = environment.getDataSource();\n"); sb.append(" entities = environment.getEntityManager();\n"); if (model.needsSession) { - sb.append(" transactionSession = new com.codename1.backend.orm." + sb.append(" transactionSession = new com.codename1.impl.backend." + "TransactionSession(\n com.codename1.backend.Backend." + "requireEntities(entities, \"the injected Session\"));\n"); } @@ -286,7 +313,7 @@ private void closeCondition(StringBuilder sb, BackendBeans.Bean b) { private String condition(BackendBeans.Bean b) { List parts = new ArrayList(); for (String[] group : b.profiles) { - StringBuilder p = new StringBuilder("com.codename1.backend.Wiring.profiles(config, " + StringBuilder p = new StringBuilder("com.codename1.impl.backend.Wiring.profiles(config, " + "new String[] {"); for (int i = 0; i < group.length; i++) { if (i > 0) { @@ -297,7 +324,7 @@ private String condition(BackendBeans.Bean b) { parts.add(p.append("})").toString()); } for (String[] c : b.propertyConditions) { - parts.add("com.codename1.backend.Wiring.propertyMatches(config, " + parts.add("com.codename1.impl.backend.Wiring.propertyMatches(config, " + BackendSources.quote(c[0]) + ", " + BackendSources.quote(c[1]) + ", " + c[2] + ")"); } @@ -314,6 +341,11 @@ private String condition(BackendBeans.Bean b) { /// The expression that constructs a bean: `new`, the constructor's bridge, or /// the factory method. private String construct(BackendBeans.Bean b) { + if (b.mockType != null) { + // A test's @MockitoBean: the JVM test runtime asks Mockito for it. + return "((" + typeOf(b) + ") com.codename1.impl.backend.test.Mocks.create(" + + typeOf(b) + ".class))"; + } StringBuilder args = new StringBuilder(); for (int i = 0; i < b.constructorPoints.size(); i++) { if (i > 0) { @@ -329,7 +361,7 @@ private String construct(BackendBeans.Bean b) { // Checked where it is produced: an injection point given the bean // directly would take the null without a word, and the server would // report ready and fail on first use, far from the factory at fault. - return "((" + typeOf(b) + ") com.codename1.backend.Wiring.produced(" + target + "." + return "((" + typeOf(b) + ") com.codename1.impl.backend.Wiring.produced(" + target + "." + name + "(" + args + "), " + BackendSources.quote("@Bean " + b.factoryOwnerClass.getSourceName() + "." + b.factory.getName()) + "))"; @@ -427,7 +459,7 @@ private void members(StringBuilder sb, BackendBeans.Bean b, String ref, String i b.propertiesPrefix.length() == 0 ? kebab : b.propertiesPrefix + "." + kebab); Type t = Type.getArgumentTypes(setter.getDescriptor())[0]; sb.append(inner).append("{\n").append(inner).append(" String value = ") - .append("com.codename1.backend.Wiring.property(config, ") + .append("com.codename1.impl.backend.Wiring.property(config, ") .append(BackendSources.quote(key)).append(", ").append(relaxed).append(");\n"); sb.append(inner).append(" if (value != null) {\n").append(inner).append(" ") .append(ref).append('.').append(setter.getName()).append('(') @@ -492,7 +524,7 @@ private void initialize(StringBuilder sb, BackendBeans.Bean b, String ref, Strin /// The expression one injection point receives. String point(BackendBeans.Point p) { if (p.value != null) { - return convert(p.type, "com.codename1.backend.Wiring.value(config, " + return convert(p.type, "com.codename1.impl.backend.Wiring.value(config, " + BackendSources.quote(p.value) + ", " + BackendSources.quote(p.where) + ")", p.where); } @@ -515,7 +547,7 @@ String point(BackendBeans.Point p) { } String type = types.typeName(p.type); if (p.list) { - StringBuilder sb = new StringBuilder("com.codename1.backend.Wiring.list(new Object[] {"); + StringBuilder sb = new StringBuilder("com.codename1.impl.backend.Wiring.list(new Object[] {"); for (int i = 0; i < p.candidates.size(); i++) { if (i > 0) { sb.append(", "); @@ -529,7 +561,7 @@ String point(BackendBeans.Point p) { } if (p.choice || (p.candidates.size() == 1 && p.candidates.get(0).isConditional())) { StringBuilder sb = new StringBuilder("(").append(type) - .append(") com.codename1.backend.Wiring.") + .append(") com.codename1.impl.backend.Wiring.") .append(p.preferFirst ? "preferred" : "single").append("(new Object[] {"); for (int i = 0; i < p.candidates.size(); i++) { if (i > 0) { @@ -554,7 +586,7 @@ private String reference(BackendBeans.Bean b) { /// Converts a configuration string to `t`. String convert(Type t, String expression, String where) { String w = BackendSources.quote(where); - String wiring = "com.codename1.backend.Wiring."; + String wiring = "com.codename1.impl.backend.Wiring."; switch (t.getSort()) { case Type.BOOLEAN: return wiring + "toBoolean(" + expression + ", " + w + ")"; case Type.CHAR: return wiring + "toChar(" + expression + ", " + w + ")"; @@ -649,7 +681,7 @@ private void started(StringBuilder sb) { sb.append(" public void started(com.codename1.backend.Backend backend) " + "throws Exception {\n"); if (model.hasJobs()) { - sb.append(" scheduler = new com.codename1.backend.Scheduler(dataSource);\n"); + sb.append(" scheduler = new com.codename1.impl.backend.Scheduler(dataSource);\n"); // Before it starts: whether its runs are measured, and whose tracer // their spans go to, are this server's. sb.append(" scheduler.bind(backend);\n"); @@ -676,7 +708,7 @@ private void started(StringBuilder sb) { private void job(StringBuilder sb, BackendBeans.Job job, String target) { String where = BackendSources.quote("@Scheduled " + job.name); String executor = BackendSources.quote(job.executor); - String thread = "com.codename1.backend.Tasks." + job.thread; + String thread = "com.codename1.impl.backend.BackendAccess." + job.thread; String lock = job.lock.length() == 0 ? "null" : BackendSources.quote(job.lock); String body = "new Runnable() {\n" + " public void run() {\n" @@ -694,13 +726,13 @@ private void job(StringBuilder sb, BackendBeans.Job job, String target) { String schedule; if (job.masks != null) { CronCompiler c = job.masks; - schedule = "new com.codename1.backend.CronSchedule(" + c.seconds + "L, " + c.minutes + schedule = "new com.codename1.impl.backend.CronSchedule(" + c.seconds + "L, " + c.minutes + "L, " + c.hours + "L, " + c.daysOfMonth + "L, " + c.months + "L, " + c.daysOfWeek + "L, " + c.lastDayOfMonth + ", " + BackendSources.quote(job.zone) + ", " + BackendSources.quote(job.cron) + ")"; } else { - schedule = "com.codename1.backend.CronSchedule.parse(" - + "com.codename1.backend.Wiring.value(config, " + schedule = "com.codename1.impl.backend.CronSchedule.parse(" + + "com.codename1.impl.backend.Wiring.value(config, " + BackendSources.quote(job.cron) + ", " + where + "), " + BackendSources.quote(job.zone) + ")"; } @@ -721,7 +753,7 @@ private void job(StringBuilder sb, BackendBeans.Job job, String target) { private static String duration(long literal, String text, String where) { if (text != null) { - return "com.codename1.backend.Wiring.toLong(com.codename1.backend.Wiring.value(" + return "com.codename1.impl.backend.Wiring.toLong(com.codename1.impl.backend.Wiring.value(" + "config, " + BackendSources.quote(text) + ", " + where + "), " + where + ")"; } return literal + "L"; @@ -773,7 +805,7 @@ private static List destroyOrder(BackendBeans.Bean b) { private static void destroyCall(StringBuilder sb, BackendBeans.Bean b, String call) { sb.append(" try {\n ").append(call).append(";\n") .append(" } catch (Throwable err) {\n") - .append(" com.codename1.backend.Wiring.destroyFailed(") + .append(" com.codename1.impl.backend.Wiring.destroyFailed(") .append(BackendSources.quote(b.name)).append(", err);\n }\n"); } @@ -965,9 +997,9 @@ private void scopes(StringBuilder sb) { sb.append(" com.codename1.backend.HttpServer.Request request =\n") .append(" com.codename1.backend.Backend.currentRequest();\n"); sb.append(" if (request == null) {\n") - .append(" throw new IllegalStateException(\"A @RequestScope bean was " + .append(" throw new IllegalStateException(\"A request-scoped bean was " + "used outside a request\");\n }\n"); - sb.append(" Object[] beans = request.scopedBeans(").append(model.requestSlots) + sb.append(" Object[] beans = com.codename1.impl.backend.BackendAccess.get().requestBeans(request, ").append(model.requestSlots) .append(");\n"); sb.append(" if (beans[slot] == null) {\n beans[slot] = " + "createScoped(slot, request, true);\n }\n"); @@ -978,15 +1010,15 @@ private void scopes(StringBuilder sb) { sb.append(" com.codename1.backend.HttpServer.Request request =\n") .append(" com.codename1.backend.Backend.currentRequest();\n"); sb.append(" if (request == null) {\n") - .append(" throw new IllegalStateException(\"A @SessionScope bean was " + .append(" throw new IllegalStateException(\"A session-scoped bean was " + "used outside a request\");\n }\n"); sb.append(" com.codename1.backend.HttpSession session = " + "request.getSession(true);\n"); // The session's shared lock, not the HttpSession: a database store // loads a separate copy per request, and locking one copy would let // two requests each build the bean. - sb.append(" synchronized (session.beanLock()) {\n"); - sb.append(" Object[] beans = session.scopedBeans(").append(model.sessionSlots) + sb.append(" synchronized (com.codename1.impl.backend.BackendAccess.get().sessionBeanLock(session)) {\n"); + sb.append(" Object[] beans = com.codename1.impl.backend.BackendAccess.get().sessionBeans(session, ").append(model.sessionSlots) .append(");\n"); sb.append(" if (beans[slot] == null) {\n beans[slot] = " + "createScoped(slot, request, false);\n }\n"); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/CronCompiler.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/CronCompiler.java index b6072fddd2c..e5c4a55dfe1 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/CronCompiler.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/CronCompiler.java @@ -26,7 +26,7 @@ import java.util.List; /// Parses a `@Scheduled(cron = ...)` expression at build time into the bit masks -/// the generated entry point hands to `com.codename1.backend.CronSchedule`. +/// the generated entry point hands to `com.codename1.impl.backend.CronSchedule`. /// /// The same grammar as that class's `parse`, which a server uses for an /// expression it reads from configuration. The two are written twice because the diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index eecba159a27..88c85b59e15 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java @@ -78,6 +78,7 @@ public final class RestControllerAnnotationProcessor extends AbstractAnnotationP private static final String PATH_VARIABLE = PKG + "PathVariable;"; private static final String REQUEST_PARAM = PKG + "RequestParam;"; private static final String REQUEST_HEADER = PKG + "RequestHeader;"; + private static final String REQUEST_PART = PKG + "RequestPart;"; private static final String REQUEST_BODY = PKG + "RequestBody;"; private static final String RESPONSE_STATUS = PKG + "ResponseStatus;"; private static final String WEBSOCKET_MAPPING = PKG + "WebSocketMapping;"; @@ -167,6 +168,14 @@ private static boolean isResponseType(String javaType) { /** Where the generated bootstrap's name is left for the packaging goal to read. */ public static final String MAIN_CLASS_RESOURCE = "META-INF/cn1-backend-main"; + /** + * Where the routers, websocket endpoints and routes the wiring was built from + * are left for the test pass, which writes a wiring of its own per test + * context and has to register the same handlers. One tab-separated record a + * line: {@code package}, {@code router}, {@code socket} and {@code route}. + */ + public static final String WIRING_RESOURCE = "META-INF/cn1-backend-wiring"; + private final TreeMap controllers = new TreeMap(); /** * Path to the source name of the endpoint serving it, sorted so the generated @@ -785,6 +794,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St AnnotationValues requestParam = annotations.get(REQUEST_PARAM); AnnotationValues requestHeader = annotations.get(REQUEST_HEADER); AnnotationValues requestBody = annotations.get(REQUEST_BODY); + AnnotationValues requestPart = annotations.get(REQUEST_PART); // EXACTLY one. The chain below is priority-ordered, so a parameter // carrying both @RequestHeader("Authorization") and @RequestParam("token") // silently bound whichever came first and read from a source the @@ -792,13 +802,14 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St // difference between a header a proxy controls and a query string the // caller writes. The contract client processor already refuses this. int bindings = (pathVariable != null ? 1 : 0) + (requestParam != null ? 1 : 0) - + (requestHeader != null ? 1 : 0) + (requestBody != null ? 1 : 0); + + (requestHeader != null ? 1 : 0) + (requestBody != null ? 1 : 0) + + (requestPart != null ? 1 : 0); if (bindings > 1) { ctx.error(cls, "Parameter " + (i + 1) + " of " + cls.getBinaryName() + "." + m.getName() + " carries more than one binding annotation. One " + "parameter reads from one place: keep @PathVariable, " - + "@RequestParam, @RequestHeader or @RequestBody, and drop the " - + "others."); + + "@RequestParam, @RequestHeader, @RequestPart or @RequestBody, and " + + "drop the others."); return null; } if (pathVariable != null) { @@ -844,6 +855,21 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St } else if (requestBody != null) { p.kind = "BODY"; p.required = requestBody.getBoolOrDefault("required", true); + } else if (requestPart != null) { + p.kind = "PART"; + p.name = requestPart.getStringOrDefault("value", ""); + p.required = requestPart.getBoolOrDefault("required", true); + if (p.name.length() == 0) { + ctx.error(cls, "@RequestPart needs the part name: " + + cls.getBinaryName() + "." + m.getName()); + return null; + } + if (!isPartType(p.javaType)) { + ctx.error(cls, "Cannot bind " + p.javaType + " from a multipart part on " + + cls.getBinaryName() + "." + m.getName() + ". A @RequestPart " + + "parameter is a HttpServer.Part, a byte[] or a String."); + return null; + } } else if (isRequestType(p.javaType)) { // The escape hatch: a handler that needs something this binding does not // model takes the Request itself, exactly as it would have before. @@ -851,8 +877,8 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St } else { ctx.error(cls, "Parameter " + (i + 1) + " of " + cls.getBinaryName() + "." + m.getName() + " has no binding annotation. Annotate it with " - + "@PathVariable, @RequestParam, @RequestHeader or @RequestBody, " - + "or declare it as HttpServer.Request"); + + "@PathVariable, @RequestParam, @RequestHeader, @RequestPart or " + + "@RequestBody, or declare it as HttpServer.Request"); return null; } p.genericJavaType = genericType; @@ -881,7 +907,7 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St if (why == null) { p.genericJavaType = bodyType; p.codecRead = codecs(ctx).readStatements(bodyType, "parsed", "com.codename1" - + ".backend.JsonCodec.Path.ROOT", "null", "-1", "0", "target", ""); + + ".impl.backend.JsonCodec.Path.ROOT", "null", "-1", "0", "target", ""); route.params.add(p); continue; } @@ -917,7 +943,8 @@ private Route buildRoute(AnnotatedClass cls, MethodInfo m, String httpMethod, St + "generates the codecs."); return null; } - if (!"REQUEST".equals(p.kind) && !isBindable(p.javaType, p.kind)) { + if (!"REQUEST".equals(p.kind) && !"PART".equals(p.kind) + && !isBindable(p.javaType, p.kind)) { ctx.error(cls, "Cannot bind " + p.javaType + " from the request on " + cls.getBinaryName() + "." + m.getName() + ". Path, query and " + "header values bind to String and the primitive types; a body " @@ -1314,8 +1341,8 @@ public void finish(ProcessorContext ctx) throws ProcessingException { if (ctx.hasErrors()) { return; } - // And one that only serves files: a static root configured in source or - // in a properties file is a server the runtime builds a router for. + // And one that only serves files: a static root in a properties file is + // a server the runtime builds a router for. boolean staticFiles = settings.values.containsKey("cn1.static.root") || applicationPropertyKnown(ctx, "cn1.static.root"); if (controllers.isEmpty() && webSockets.isEmpty() && !beans.hasJobs() @@ -1337,6 +1364,10 @@ public void finish(ProcessorContext ctx) throws ProcessingException { + "; a build without clean may still package a backend entry " + "point for controllers that no longer exist"); } + File record = new File(ctx.getOutputClassDir(), WIRING_RESOURCE); + if (record.isFile() && !record.delete()) { + ctx.getLog().warn("could not remove the stale " + WIRING_RESOURCE); + } return; } resolveTelemetry(ctx); @@ -1412,6 +1443,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { } JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); ctx.emitResource(MAIN_CLASS_RESOURCE, asciiBytes(bootstrap)); + ctx.emitResource(WIRING_RESOURCE, asciiBytes(wiringRecord(entryPackage))); } catch (IOException ioe) { throw new ProcessingException("Could not compile the generated @RestController " + "sources: " + ioe.getMessage(), ioe); @@ -1572,6 +1604,7 @@ private static void emitRoute(StringBuilder sb, Route route, int index, Controll sb.append(pad).append("com.codename1.backend.Tracing.route(") .append(quote(route.pattern)).append(");\n"); + emitPartGuards(sb, route, pad); emitRequiredGuards(sb, route, pad); emitScalarGuards(sb, route, pad); emitBodyLocals(sb, route, pad); @@ -1649,13 +1682,81 @@ private static boolean optionalCannotBeAbsent(Param p) { if (p.required || (p.defaultValue != null && p.defaultValue.length() > 0)) { return false; } - if ("PATH".equals(p.kind) || "REQUEST".equals(p.kind) || "BODY".equals(p.kind)) { + if ("PATH".equals(p.kind) || "REQUEST".equals(p.kind) || "BODY".equals(p.kind) + || "PART".equals(p.kind)) { return false; } return p.javaType != null && p.javaType.indexOf('.') < 0 && !"void".equals(p.javaType); } + /// The part types a `@RequestPart` binds to, in either spelling of the nested + /// class. + private static boolean isPartType(String javaType) { + return "com.codename1.backend.HttpServer.Part".equals(javaType) + || "com.codename1.backend.HttpServer$Part".equals(javaType) + || "byte[]".equals(javaType) || "java.lang.String".equals(javaType); + } + + /** + * Refuses what the client sent malformed before anything is bound: a body + * that is not UTF-8 for a body binding, a request parameter that cannot be + * read, a malformed multipart body, and a String part that is not text. getParts() throws for a body that claims to be + * multipart and is not, which unguarded would surface as a 500 from inside the + * argument list -- for what is the client's syntax error. + */ + private static void emitPartGuards(StringBuilder sb, Route route, String pad) { + boolean any = false; + boolean bodyChecked = false; + for (int i = 0; i < route.params.size(); i++) { + Param p = route.params.get(i); + if ("BODY".equals(p.kind)) { + // Every body binding reads it as text, and a binary-typed body is + // not checked for UTF-8 on arrival, so getBody() throws for one that + // is not: refused as the client's error before anything reads it. + if (!bodyChecked) { + bodyChecked = true; + sb.append(pad).append("if (!bodyIsText(request)) {\n"); + sb.append(pad).append(" return request.respond(400, \"text/plain; charset=utf-8\",\n"); + sb.append(pad).append(" utf8(\"The request body is not UTF-8 text\"));\n"); + sb.append(pad).append("}\n"); + } + continue; + } + if ("QUERY".equals(p.kind)) { + // A @RequestParam can be a multipart form field, and Request.param + // throws for a malformed multipart body or a field that is not + // UTF-8: refused here as the client's error, not left to surface + // as a 500 from the argument list. + sb.append(pad).append("String unreadable").append(i).append(" = paramProblem(request, ") + .append(quote(p.name)).append(");\n"); + sb.append(pad).append("if (unreadable").append(i).append(" != null) {\n"); + sb.append(pad).append(" return request.respond(400, \"text/plain; charset=utf-8\",\n"); + sb.append(pad).append(" utf8(unreadable").append(i).append("));\n"); + sb.append(pad).append("}\n"); + continue; + } + if (!"PART".equals(p.kind)) { + continue; + } + if (!any) { + any = true; + sb.append(pad).append("if (!partsWellFormed(request)) {\n"); + sb.append(pad).append(" return request.respond(400, \"text/plain; charset=utf-8\",\n"); + sb.append(pad).append(" utf8(\"The multipart body is malformed\"));\n"); + sb.append(pad).append("}\n"); + } + if ("java.lang.String".equals(p.javaType)) { + sb.append(pad).append("if (!partIsText(request.getPart(").append(quote(p.name)) + .append("))) {\n"); + sb.append(pad).append(" return request.respond(400, \"text/plain; charset=utf-8\",\n"); + sb.append(pad).append(" utf8(").append(quote("The part " + p.name + + " is not UTF-8 text")).append("));\n"); + sb.append(pad).append("}\n"); + } + } + } + private static void emitRequiredGuards(StringBuilder sb, Route route, String pad) { for (int i = 0; i < route.params.size(); i++) { Param p = route.params.get(i); @@ -1665,11 +1766,14 @@ private static void emitRequiredGuards(StringBuilder sb, Route route, String pad String test; String what; if ("QUERY".equals(p.kind)) { - test = "request.queryParam(" + quote(p.name) + ") == null"; + test = "request.param(" + quote(p.name) + ") == null"; what = "query parameter " + p.name; } else if ("HEADER".equals(p.kind)) { test = "request.getHeader(" + quote(p.name) + ") == null"; what = "header " + p.name; + } else if ("PART".equals(p.kind)) { + test = "request.getPart(" + quote(p.name) + ") == null"; + what = "part " + p.name; } else if ("BODY".equals(p.kind)) { test = "request.getBody() == null || request.getBody().length() == 0"; what = "request body"; @@ -1722,7 +1826,7 @@ private static void emitScalarGuards(StringBuilder sb, Route route, String pad) raw = "bound[" + p.variableIndex + "]"; what = "path variable " + p.name; } else if ("QUERY".equals(p.kind)) { - raw = "request.queryParam(" + quote(p.name) + ")"; + raw = "request.param(" + quote(p.name) + ")"; what = "query parameter " + p.name; } else if ("HEADER".equals(p.kind)) { raw = "request.getHeader(" + quote(p.name) + ")"; @@ -2208,9 +2312,18 @@ private static String argumentExpression(Param p) { if ("PATH".equals(p.kind)) { raw = "bound[" + p.variableIndex + "]"; } else if ("QUERY".equals(p.kind)) { - raw = "request.queryParam(" + quote(p.name) + ")"; + raw = "request.param(" + quote(p.name) + ")"; } else if ("HEADER".equals(p.kind)) { raw = "request.getHeader(" + quote(p.name) + ")"; + } else if ("PART".equals(p.kind)) { + String part = "request.getPart(" + quote(p.name) + ")"; + if ("byte[]".equals(p.javaType)) { + return "partBytes(" + part + ")"; + } + if ("java.lang.String".equals(p.javaType)) { + return "partText(" + part + ")"; + } + return part; } else { return bodyExpression(p); } @@ -2272,6 +2385,47 @@ private static void appendIndented(StringBuilder sb, String code, String pad) { private static void emitRouterHelpers(StringBuilder sb) { sb.append(" private static final byte[] EMPTY = new byte[0];\n\n"); + // The multipart helpers. Unused ones go with the dead-code pass. + sb.append(" private static boolean partsWellFormed(com.codename1.backend.HttpServer.Request request) {\n"); + sb.append(" try {\n"); + sb.append(" request.getParts();\n"); + sb.append(" return true;\n"); + sb.append(" } catch (IllegalArgumentException malformed) {\n"); + sb.append(" return false;\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); + sb.append(" private static boolean partIsText(com.codename1.backend.HttpServer.Part part) {\n"); + sb.append(" try {\n"); + sb.append(" return part == null || part.getText() != null;\n"); + sb.append(" } catch (IllegalStateException binary) {\n"); + sb.append(" return false;\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); + sb.append(" private static boolean bodyIsText(com.codename1.backend.HttpServer.Request request) {\n"); + sb.append(" try {\n"); + sb.append(" request.getBody();\n"); + sb.append(" return true;\n"); + sb.append(" } catch (IllegalStateException binary) {\n"); + sb.append(" return false;\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); + sb.append(" private static String paramProblem(com.codename1.backend.HttpServer.Request request, " + + "String name) {\n"); + sb.append(" try {\n"); + sb.append(" request.param(name);\n"); + sb.append(" return null;\n"); + sb.append(" } catch (IllegalArgumentException malformed) {\n"); + sb.append(" return \"The multipart body is malformed\";\n"); + sb.append(" } catch (IllegalStateException binary) {\n"); + sb.append(" return \"The parameter \" + name + \" is not UTF-8 text\";\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); + sb.append(" private static byte[] partBytes(com.codename1.backend.HttpServer.Part part) {\n"); + sb.append(" return part == null ? null : part.getBytes();\n"); + sb.append(" }\n\n"); + sb.append(" private static String partText(com.codename1.backend.HttpServer.Part part) {\n"); + sb.append(" return part == null ? null : part.getText();\n"); + sb.append(" }\n\n"); sb.append(" /**\n"); sb.append(" * Binds the path variables, or returns null when the rest of the path is\n"); sb.append(" * not this route after all. `after[i]` is the literal that follows\n"); @@ -2517,7 +2671,7 @@ private static void emitRouterHelpers(StringBuilder sb) { sb.append(" private static boolean parses").append(numeric[i][0]) .append("(String value) {\n"); // ABSENT is fine; present and EMPTY is not. "?count=" is a parameter - // the client sent, and queryParam distinguishes it from one that was + // the client sent, and param() distinguishes it from one that was // omitted -- so treating the two alike let an empty value take the // default or zero and call the handler with a number nobody sent, // which is the same defect as accepting "zz". A default is documented @@ -2668,17 +2822,23 @@ String generateBootstrap(String packageName) { // left here is the part that differs between one server and the next, // and even that lives in the generated BackendWiring: every bean, built // and injected by straight-line code. - sb.append(" com.codename1.backend.Backend.builder()\n"); + // The builder's public calls first; the build's own -- compiled settings, + // the management and MCP endpoints, the wiring -- go through the + // runtime's internal access, so none of them is public API. + sb.append(" com.codename1.impl.backend.BackendAccess cn1Access =\n") + .append(" com.codename1.impl.backend.BackendAccess.get();\n"); + sb.append(" com.codename1.backend.Backend.Builder cn1Builder = " + + "com.codename1.backend.Backend.builder()"); if (telemetry) { // The ONLY references to the OTLP exporters anywhere in the program, // which is what keeps them out of a binary that does not ask for // them: the translator drops what nothing reaches. String name = telemetryServiceName == null || telemetryServiceName.length() == 0 ? "null" : quote(telemetryServiceName); - sb.append(" .tracing(new com.codename1.backend.otel.OtlpTracer(") - .append(name).append("))\n"); - sb.append(" .metrics(new com.codename1.backend.otel.OtlpMetricExporter(") - .append(name).append("))\n"); + sb.append("\n .tracing(new com.codename1.backend.otel.OtlpTracer(") + .append(name).append("))"); + sb.append("\n .metrics(new com.codename1.backend.otel.OtlpMetricExporter(") + .append(name).append("))"); } if (beans != null && beans.needsDatabase) { // A bean that declares a DataSource, an EntityManager or a Session @@ -2686,43 +2846,65 @@ String generateBootstrap(String packageName) { // opens one for a server that has no entities either, which is how a // development profile's in-memory default reaches a bean that asked // only for the pool. - sb.append(" .requiresDataSource()\n"); + sb.append("\n .requiresDataSource()"); } + sb.append(";\n"); if (settings != null && !settings.values.isEmpty()) { - // The settings annotations, as the bottom layer of the configuration. - sb.append(" .compiledSettings(new String[] {"); + // application.properties, as the bottom layer of the configuration. + sb.append(" cn1Access.compiledSettings(cn1Builder, new String[] {"); List flat = settings.flat(); for (int i = 0; i < flat.size(); i++) { sb.append(i == 0 ? "" : ", ").append(quote(flat.get(i))); } - sb.append("})\n"); + sb.append("});\n"); } if (devTools || (settings != null && settings.management)) { // The ONLY call that names the management endpoints, so a packaged // server that did not ask for them has none of their code: the // translator drops the builder method nothing calls, and the classes // only it named go with it. - sb.append(" .management()\n"); + sb.append(" cn1Access.management(cn1Builder);\n"); } boolean tools = beans != null && beans.hasTools(); if (devTools || tools || (settings != null && settings.mcp)) { // The development tools are named only in a development build, so a // packaged server has none of their code. - sb.append(" .mcp(").append(devTools - ? "new com.codename1.backend.mcp.DevTools()" : "null").append(")\n"); + sb.append(" cn1Access.mcp(cn1Builder, ").append(devTools + ? "new com.codename1.impl.backend.mcp.DevTools()" : "null").append(");\n"); if (telemetryServiceName != null && telemetryServiceName.length() > 0) { - sb.append(" .serviceName(").append(quote(telemetryServiceName)) - .append(")\n"); + sb.append(" cn1Access.serviceName(cn1Builder, ") + .append(quote(telemetryServiceName)).append(");\n"); } } - sb.append(" .application(new ") - .append(qualify(packageName, BackendWiringWriter.CLASS_NAME)).append("())\n"); - sb.append(" .run();\n"); + sb.append(" cn1Access.application(cn1Builder, new ") + .append(qualify(packageName, BackendWiringWriter.CLASS_NAME)).append("());\n"); + sb.append(" cn1Builder.run();\n"); sb.append(" }\n"); sb.append("}\n"); return sb.toString(); } + /// What [#WIRING_RESOURCE] holds for this build. + String wiringRecord(String packageName) { + StringBuilder sb = new StringBuilder(); + sb.append("package\t").append(packageName).append('\n'); + for (Controller c : controllers.values()) { + sb.append("router\t").append(c.binaryName).append('\t') + .append(qualify(c.packageName, c.routerSimpleName)).append('\n'); + for (Route r : c.routes) { + sb.append("route\t").append(r.httpMethod).append('\t').append(r.pattern) + .append('\t').append(c.binaryName).append('.').append(r.javaMethod) + .append('\t').append(c.binaryName).append('\n'); + } + } + for (WebSocketEndpoint e : webSockets.values()) { + sb.append("socket\t").append(e.path).append('\t').append(e.binaryName).append('\n'); + sb.append("route\tWEBSOCKET\t").append(e.path).append('\t').append(e.binaryName) + .append('\t').append(e.binaryName).append('\n'); + } + return sb.toString(); + } + /// The source of the generated BackendWiring, package-visible so a test can /// read what the build decided. String generateWiring(String packageName) { @@ -2905,6 +3087,54 @@ static boolean applicationPropertyKnown(ProcessorContext ctx, String key) { return false; } + /// The module's base `application.properties`, loaded, or null when it has + /// none or it can't be read. + static java.util.Properties baseApplicationProperties(ProcessorContext ctx) { + return loadProperties(ctx, applicationProperties(ctx)); + } + + /// The module's `application-.properties`, loaded, or null when it + /// has none. Looked for where the base file is looked for, and independently + /// of it: a module may keep only `application-test.properties`, and a test on + /// that profile must still get its settings. + static java.util.Properties profileApplicationProperties(ProcessorContext ctx, + String profile) { + if (profile == null || profile.length() == 0) { + return null; + } + return loadProperties(ctx, findProperties(ctx, "application-" + profile + ".properties")); + } + + /// `f` loaded as UTF-8, or null when it is null or unreadable. As UTF-8 + /// because that is how Config reads the same file at run time: + /// Properties.load(InputStream) decodes ISO-8859-1, so a value with an accent + /// in it -- a password -- was compiled in as different characters from the + /// ones the server reads from the file. + private static java.util.Properties loadProperties(ProcessorContext ctx, File f) { + if (f == null) { + return null; + } + java.util.Properties props = new java.util.Properties(); + java.io.Reader in = null; + try { + in = new java.io.InputStreamReader(new java.io.FileInputStream(f), + java.nio.charset.StandardCharsets.UTF_8); + props.load(in); + return props; + } catch (IOException err) { + ctx.getLog().warn("Could not read " + f + ": " + err.getMessage()); + return null; + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + // Only read; nothing to lose. + } + } + } + } + /// The backend module's `application.properties`, or null. /// /// Looked for beside the module that owns the output directory FIRST @@ -2914,6 +3144,11 @@ static boolean applicationPropertyKnown(ProcessorContext ctx, String key) { /// in a multi-module application is `common`. The packaging goal does pass the /// module itself, which is the fallback. private static File applicationProperties(ProcessorContext ctx) { + return findProperties(ctx, "application.properties"); + } + + /// `name` in the places [#applicationProperties] searches, in its order. + private static File findProperties(ProcessorContext ctx, String name) { // Where a project keeps it, in the order Maven would see it. The copy in // the classes directory FIRST: the documented layout is // src/main/resources/application.properties, which process-resources has @@ -2925,18 +3160,18 @@ private static File applicationProperties(ProcessorContext ctx) { List candidates = new ArrayList(); File classes = ctx.getOutputClassDir(); if (classes != null) { - candidates.add(new File(classes, "application.properties")); + candidates.add(new File(classes, name)); if (classes.getParentFile() != null && "target".equals(classes.getParentFile().getName())) { File module = classes.getParentFile().getParentFile(); - candidates.add(new File(module, "src/main/resources/application.properties")); - candidates.add(new File(module, "application.properties")); + candidates.add(new File(module, "src/main/resources/" + name)); + candidates.add(new File(module, name)); } } File dir = ctx.getProjectDir(); if (dir != null) { - candidates.add(new File(dir, "src/main/resources/application.properties")); - candidates.add(new File(dir, "application.properties")); + candidates.add(new File(dir, "src/main/resources/" + name)); + candidates.add(new File(dir, name)); } for (File file : candidates) { if (file.isFile()) { diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/ApiTest.java.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/ApiTest.java.txt new file mode 100644 index 00000000000..ffe68336a7d --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/ApiTest.java.txt @@ -0,0 +1,46 @@ +package ${package}; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.content; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +/** + * Tests for Api, in the shape of Spring Boot's: the annotations and the MockMvc + * calls are Spring's, under Codename One's package names. + * + * @BackendTest starts this module's server, wired exactly as it ships, and + * injects what the test asks for. MockMvc dispatches a request in process, + * without a socket; TestRestTemplate (with a RANDOM_PORT web environment) sends + * it over a real port instead, as ServedApiTest does. Run them with + * + * ./gradlew __BACKEND__test + * + * and `./gradlew __BACKEND__backendTest` to translate the same tests into a native + * test binary and run that, which is how the server ships. + */ +@BackendTest +class ApiTest { + @Autowired + private MockMvc mvc; + + @Test + void greetsThroughTheInjectedService() throws Exception { + mvc.perform(get("/greet/{name}", "Ada")) + .andExpect(status().isOk()) + .andExpect(content().string("Hello, Ada")); + } + + @Test + void echoAnswersJson() throws Exception { + mvc.perform(get("/echo").param("say", "hi")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.say").value("hi")); + mvc.perform(get("/echo")).andExpect(jsonPath("$.say").value("hello")); + } +} diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt index b1a86333b24..050afd1a398 100644 --- a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/Greeter.java.txt @@ -1,11 +1,11 @@ package ${package}; -import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Component; /** * A service: business logic with no HTTP in it, which the controller is given. * - * The build finds every @Service, @Component and @Repository, works out + * The build finds every @Component, works out * what each one's constructor needs, and writes the `new` calls into the entry * point it generates -- so this reads like Spring, and there is no container, no * reflection and no start-up scan once it runs. A missing or ambiguous dependency @@ -16,7 +16,7 @@ import com.codename1.backend.annotations.Service; * @McpTool methods an agent can call. See the Backend chapter of the developer * guide, or the backend reference in this project's agent skill. */ -@Service +@Component public class Greeter { /** Greets someone. */ public String greet(String name) { diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/ServedApiTest.java.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/ServedApiTest.java.txt new file mode 100644 index 00000000000..f8b492fd375 --- /dev/null +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/ServedApiTest.java.txt @@ -0,0 +1,28 @@ +package ${package}; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.HttpStatus; +import com.codename1.backend.test.TestRestTemplate; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The same server on a real port, reached through the backend's own HTTP client: + * the request goes through the socket, the parser and the response writer that a + * deployed server uses. The port is chosen at random, so a server already running + * on 8080 does not get in the way. + */ +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class ServedApiTest { + @Autowired + private TestRestTemplate rest; + + @Test + void answersOverTheNetwork() throws Exception { + assertEquals("ok", rest.getForObject("/healthz", String.class)); + assertEquals("Hello, Ada", rest.getForObject("/greet/{name}", String.class, "Ada")); + assertEquals(HttpStatus.NOT_FOUND, rest.getForEntity("/nowhere", String.class).getStatusCode()); + } +} diff --git a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt index 1d3b7b6cd9b..2cca970359e 100644 --- a/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt +++ b/maven/build-engine/src/main/resources/com/codename1/project/templates/gradle/backend/build.gradle.kts.txt @@ -2,6 +2,8 @@ // // ./gradlew runBackend run on this JVM (starts in seconds) // ./gradlew backendPackage build a single native binary +// ./gradlew test run the tests (@BackendTest, MockMvc, JUnit 5) +// ./gradlew backendTest run the same tests as a native binary dependencies { // implementation("org.example:library:1.0") } diff --git a/maven/build-engine/src/test/java/com/codename1/backend/test/HttpStatusTest.java b/maven/build-engine/src/test/java/com/codename1/backend/test/HttpStatusTest.java new file mode 100644 index 00000000000..0899fac2b3c --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/backend/test/HttpStatusTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The status classification of the backend test API, which has no tests of its +/// own module; in its package to reach the package-private factory a response +/// uses for a code with no constant. +class HttpStatusTest { + @Test + void onlyFourAndFiveHundredsAreErrors() { + assertFalse(HttpStatus.of(600).isError(), "a 600 is neither 4xx nor 5xx"); + assertFalse(HttpStatus.of(299).isError()); + assertTrue(HttpStatus.of(499).isError(), "a 4xx with no constant"); + assertTrue(HttpStatus.NOT_FOUND.isError()); + assertTrue(HttpStatus.SERVICE_UNAVAILABLE.isError()); + assertFalse(HttpStatus.OK.isError()); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/BackendTestPackagerTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BackendTestPackagerTest.java new file mode 100644 index 00000000000..9a2b64cfa08 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/BackendTestPackagerTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Which test sources the compiled run leaves out for using Mockito. +class BackendTestPackagerTest { + @Test + void codeThatUsesMockitoIsLeftOut() { + assertTrue(BackendTestPackager.usesMockito("import org.mockito.Mockito;\nclass A {}\n")); + assertTrue(BackendTestPackager.usesMockito("import static org.mockito.Mockito.when;\n")); + assertTrue(BackendTestPackager.usesMockito("class A { Object m = org.mockito.Mockito.mock(A.class); }")); + } + + @Test + void aSourceIsSelectedByTheClassItsPathNames() { + java.io.File root = new java.io.File("/tmp/project/src/test/java"); + assertTrue("com.acme.ApiTest".equals(BackendTestPackager.binaryName(root, + new java.io.File(root, "com/acme/ApiTest.java")))); + assertTrue("TopLevelTest".equals(BackendTestPackager.binaryName(root, + new java.io.File(root, "TopLevelTest.java"))), "the default package"); + } + + @Test + void aSourceIsKnownByTheClassesItDeclares(@org.junit.jupiter.api.io.TempDir java.io.File dir) + throws Exception { + // Fixtures.java declares a package-private ApiTest, which Surefire runs as + // ApiTest; selecting the file by its own name lost it. + java.io.File src = new java.io.File(dir, "src/com/acme"); + src.mkdirs(); + java.io.File fixtures = new java.io.File(src, "Fixtures.java"); + java.nio.file.Files.write(fixtures.toPath(), ("package com.acme;\n" + + "class ApiTest { class Inner {} }\n").getBytes("UTF-8")); + java.io.File out = new java.io.File(dir, "classes"); + out.mkdirs(); + javax.tools.JavaCompiler javac = javax.tools.ToolProvider.getSystemJavaCompiler(); + assertTrue(javac.run(null, null, null, "-d", out.getAbsolutePath(), + fixtures.getAbsolutePath()) == 0, "the fixture did not compile"); + java.util.Map> declared = + BackendTestPackager.declaredClasses(out); + assertTrue(java.util.Collections.singletonList("com.acme.ApiTest") + .equals(declared.get("com/acme/Fixtures.java")), String.valueOf(declared)); + assertTrue(BackendTestPackager.declaredClasses(new java.io.File(dir, "missing")).isEmpty()); + } + + @Test + void theWordsInCommentsAndStringsAreNotAUse() { + assertFalse(BackendTestPackager.usesMockito("// unlike org.mockito, this needs nothing\nclass A {}\n")); + assertFalse(BackendTestPackager.usesMockito("/** Not org.mockito: a fake. */\nclass A {}\n")); + assertFalse(BackendTestPackager.usesMockito( + "class A { String s = \"org.mockito.Mockito\"; char c = '\"'; }\n")); + assertFalse(BackendTestPackager.usesMockito( + "class A { String s = \"say \\\"org.mockito\\\"\"; }\n")); + assertTrue(BackendTestPackager.usesMockito( + "class A { String s = \"x\"; Object m = org.mockito.Mockito.mock(A.class); }\n"), + "code after a string is still read"); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java b/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java index ec26639dcf3..7e54f6ea152 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/GradleConversionTest.java @@ -471,6 +471,34 @@ void aBackendWhosePomWasChangedIsKeptEvenWithTheGeneratedCode() throws Exception "the archetype's own pom is still the skeleton"); } + @Test + void theArchetypesSampleTestsStillCountAsTheSkeleton() throws Exception { + // The archetype now writes ApiTest and ServedApiTest beside the generated + // code, with the test library, JUnit and Surefire in the pom. None of that + // is the developer's work, so the conversion still leaves it behind. + File mvn = mavenApp(UNTOUCHED_API); + touch(mvn, "backend/pom.xml", GENERATED_BACKEND_POM + .replace("", "com.codenameone" + + "codenameone-backend-testtest" + + "org.junit.jupiterjunit-jupiter" + + "5.9.3test") + .replace("", "org.apache.maven.plugins" + + "maven-surefire-plugin")); + String apiTest = template("backend/ApiTest.java.txt").replace("${package}", "a.backend") + .replace("__BACKEND__", ":backend:"); + touch(mvn, "backend/src/test/java/a/backend/ApiTest.java", apiTest); + touch(mvn, "backend/src/test/java/a/backend/ServedApiTest.java", + template("backend/ServedApiTest.java.txt").replace("${package}", "a.backend") + .replace("__BACKEND__", ":backend:")); + assertTrue(GradleConversion.isUntouchedSkeleton(new File(mvn, "backend")), + "the generated tests are part of the skeleton"); + // A test the developer changed is their work. + touch(mvn, "backend/src/test/java/a/backend/ApiTest.java", + apiTest.replace("\"Hello, Ada\"", "\"Hi, Ada\"")); + assertFalse(GradleConversion.isUntouchedSkeleton(new File(mvn, "backend")), + "an edited generated test is someone's work"); + } + @Test void whatThePomReplacesIsLeftOutAndTargetPathsAreKept() throws Exception { File mvn = mavenApp(UNTOUCHED_API); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index e70271c02f1..fe1a367e6aa 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendBeansTest.java @@ -23,6 +23,8 @@ package com.codename1.maven.processors; import com.codename1.backend.Backend; +import com.codename1.impl.backend.BackendAccess; +import com.codename1.impl.backend.BackendApplication; import com.codename1.backend.Config; import com.codename1.backend.HttpServer; import com.codename1.maven.annotations.AnnotatedClass; @@ -36,6 +38,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; @@ -54,6 +57,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -80,7 +84,7 @@ private static Map sample() { s.put("com.example.Greeter", PKG + "public interface Greeter { String greet(String name); }\n"); s.put("com.example.PoliteGreeter", PKG - + "@Service\n" + + "@Component\n" + "public class PoliteGreeter implements Greeter {\n" + " @Value(\"${greeting.prefix:Hello}\") private String prefix;\n" + " private int initialized;\n" @@ -90,7 +94,7 @@ private static Map sample() { + " }\n" + "}\n"); s.put("com.example.Notes", PKG - + "@Repository\n" + + "@Component\n" + "public class Notes {\n" + " private final DataSource db;\n" + " public Notes(DataSource db) { this.db = db; }\n" @@ -110,7 +114,7 @@ private static Map sample() { + " }\n" + "}\n"); s.put("com.example.Jobs", PKG - + "@Service\n" + + "@Component\n" + "public class Jobs {\n" + " public final AtomicInteger runs = new AtomicInteger();\n" + " @Scheduled(fixedRate = 20)\n" @@ -122,7 +126,7 @@ private static Map sample() { + " }\n" + "}\n"); s.put("com.example.RequestInfo", PKG - + "@Component @RequestScope\n" + + "@Component @Scope(\"request\")\n" + "public class RequestInfo {\n" + " private final String id = String.valueOf(System.nanoTime());\n" + " public String id() { return id; }\n" @@ -176,18 +180,16 @@ public void theGeneratedWiringRunsTheSample() throws Exception { assertNoErrors(ctx); URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader + BackendApplication app = (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance(); int port = freePort(); Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); settings.setProperty("greeting.prefix", "Hi"); // What the generated main does, with the development tools a JVM build has. - Backend backend = Backend.builder(Config.of(settings, "dev")) + Backend backend = withApplication(withManagement(withMcp(Backend.builder(Config.of(settings, "dev")) .quiet() - .requiresDataSource() - .mcp(new com.codename1.backend.mcp.DevTools()).management() - .application(app) + .requiresDataSource(), new com.codename1.impl.backend.mcp.DevTools())), app) .start(); try { // Field injection into a private field, @Value, and a package-private @@ -276,7 +278,7 @@ public void theEntryPointNamesTheWiring() throws Exception { ProcessorContext ctx = process(classes, proc); assertNoErrors(ctx); String bootstrap = proc.generateBootstrap("com.example"); - assertTrue(bootstrap, bootstrap.contains(".application(new com.example.BackendWiring())")); + assertTrue(bootstrap, bootstrap.contains("cn1Access.application(cn1Builder, new com.example.BackendWiring())")); assertTrue("a bean takes a DataSource, so the entry point must ask for a database:\n" + bootstrap, bootstrap.contains(".requiresDataSource()")); String wiring = proc.generateWiring("com.example"); @@ -427,12 +429,12 @@ public void theGuidesOrderExampleWorksAsDocumented() throws Exception { assertNoErrors(process(classes)); URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader.loadClass( + BackendApplication app = (BackendApplication) loader.loadClass( "com.codenameone.developerguide.backend.orders.BackendWiring").newInstance(); int port = freePort(); Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); - Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().application(app) + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), app) .start(); try { String placed = post(port, "/orders", @@ -451,7 +453,7 @@ public void anMcpToolReadsAndWritesItsTypesThroughTheCodecs() throws Exception { Map s = new LinkedHashMap(); s.put("com.example.Tag", PKG + "public class Tag { public String name; }\n"); s.put("com.example.Shop", PKG - + "@Service public class Shop {\n" + + "@Component public class Shop {\n" + " @McpTool(description = \"Totals the ids under a tag\")\n" + " public Tag total(@McpParam(\"ids\") List ids,\n" + " @McpParam(\"tag\") Tag tag) {\n" @@ -467,13 +469,12 @@ public void anMcpToolReadsAndWritesItsTypesThroughTheCodecs() throws Exception { assertNoErrors(process(classes)); URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader + BackendApplication app = (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance(); int port = freePort(); Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); - Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().mcp(null) - .application(app).start(); + Backend backend = withApplication(withMcp(Backend.builder(Config.of(settings, "dev")).quiet(), null), app).start(); try { String tools = post(port, "/mcp", "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":" + "\"tools/list\"}"); @@ -504,7 +505,7 @@ public void anMcpToolReadsAndWritesItsTypesThroughTheCodecs() throws Exception { public void anMcpToolTheCodecsCannotServeIsABuildError() throws Exception { Map s = new LinkedHashMap(); s.put("com.example.Shop", PKG - + "@Service public class Shop {\n" + + "@Component public class Shop {\n" + " @McpTool(description = \"a\")\n" + " public String a(@McpParam(\"ids\") int[] ids) { return \"a\"; }\n" + " @McpTool(description = \"b\")\n" @@ -536,7 +537,7 @@ public void twoClassesFoldingToOneCodecNameFailTheBuild() throws Exception { @Test public void aProjectClassWithAGeneratedNameFailsTheBuild() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Foo", PKG + "@Service public class Foo {\n" + s.put("com.example.Foo", PKG + "@Component public class Foo {\n" + " @Timed(\"foo.work\") public void work() { }\n" + "}\n"); // Exactly the support class the build would generate for Foo's aspects. @@ -581,10 +582,11 @@ public void aSubclassReachingTheTypeThroughADependencyIsDispatched() throws Exce @Test public void aServerWithOnlyManagementStillGetsAnEntryPoint() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Ops", PKG + "@Configuration @EnableManagement public class Ops { }\n"); + s.put("com.example.Ops", PKG + "@Configuration public class Ops { }\n"); File classes = compile(s); + writeProperties(classes, "application.properties", "cn1.management.enabled=true\n"); assertNoErrors(process(classes)); - assertTrue("@EnableManagement alone produced nothing runnable", + assertTrue("cn1.management.enabled alone produced nothing runnable", new File(classes, "com/example/BackendApplication.class").isFile()); } @@ -603,14 +605,13 @@ public void aManagedOperationReturnsItsDtoAsJson() throws Exception { assertNoErrors(process(classes)); URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader + BackendApplication app = (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance(); int port = freePort(); Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); settings.setProperty("cn1.management.token", "t0k"); - Backend backend = Backend.builder(Config.of(settings, "dev")).quiet().management() - .application(app).start(); + Backend backend = withApplication(withManagement(Backend.builder(Config.of(settings, "dev")).quiet()), app).start(); try { HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/manage/managed/reports/latest").openConnection(); @@ -654,11 +655,11 @@ public void aManagedAttributeThatIsNotAGaugeFailsTheBuild() throws Exception { @Test public void aServerThatOnlyServesFilesStillGetsAnEntryPoint() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Site", PKG - + "@Configuration @StaticFilesConfig(root = \"www\") public class Site { }\n"); + s.put("com.example.Site", PKG + "@Configuration public class Site { }\n"); File classes = compile(s); + writeProperties(classes, "application.properties", "cn1.static.root=www\n"); assertNoErrors(process(classes)); - assertTrue("@StaticFilesConfig alone produced nothing runnable", + assertTrue("cn1.static.root alone produced nothing runnable", new File(classes, "com/example/BackendApplication.class").isFile()); } @@ -717,30 +718,29 @@ public void aPackagedServerLinksManagementAndMcpOnlyWhenAsked() throws Exception assertNoErrors(process(classes, proc)); String bootstrap = proc.generateBootstrap("com.example"); assertFalse("nothing asked for management, yet the entry point names it:\n" + bootstrap, - bootstrap.contains(".management()")); + bootstrap.contains("cn1Access.management(cn1Builder)")); assertFalse("nothing asked for MCP, yet the entry point names it:\n" + bootstrap, - bootstrap.contains(".mcp(")); - assertFalse(bootstrap, bootstrap.contains(".compiledSettings(")); - - Map s = new LinkedHashMap(plain); - s.put("com.example.Settings", PKG - + "@EnableManagement(path = \"/ops\") @EnableMcpServer(allowedOrigins = " - + "{\"https://a.example\", \"https://b.example\"})\n" - + "public class Settings { }\n"); + bootstrap.contains("cn1Access.mcp(")); + assertFalse(bootstrap, bootstrap.contains("cn1Access.compiledSettings(")); + + classes = compile(plain); + writeProperties(classes, "application.properties", "cn1.management.enabled=true\n" + + "cn1.management.path=/ops\n" + + "cn1.mcp.allowedOrigins=https://a.example,https://b.example\n"); proc = new RestControllerAnnotationProcessor(); proc.setDevTools(false); - assertNoErrors(process(compile(s), proc)); + assertNoErrors(process(classes, proc)); bootstrap = proc.generateBootstrap("com.example"); - assertTrue(bootstrap, bootstrap.contains(".management()")); - assertTrue(bootstrap, bootstrap.contains(".mcp(null)")); + assertTrue(bootstrap, bootstrap.contains("cn1Access.management(cn1Builder)")); + assertTrue(bootstrap, bootstrap.contains("cn1Access.mcp(cn1Builder, null)")); assertTrue(bootstrap, bootstrap.contains("\"cn1.management.enabled\", \"true\"")); assertTrue(bootstrap, bootstrap.contains("\"cn1.management.path\", \"/ops\"")); assertTrue(bootstrap, bootstrap.contains( "\"cn1.mcp.allowedOrigins\", \"https://a.example,https://b.example\"")); - assertFalse("@EnableMcpServer links the endpoint; whether it serves stays the " + assertFalse("naming the origins links the endpoint; whether it serves stays the " + "endpoint's own default:\n" + bootstrap, bootstrap.contains("cn1.mcp.enabled")); - // The property, in a profile's file, asks as surely as the annotation. + // The property, in a profile's file, asks as surely as the base file. classes = compile(plain); java.io.FileWriter w = new java.io.FileWriter(new File(classes, "application.properties")); w.write("cn1.profile=prod\n"); @@ -751,64 +751,47 @@ public void aPackagedServerLinksManagementAndMcpOnlyWhenAsked() throws Exception proc = new RestControllerAnnotationProcessor(); proc.setDevTools(false); assertNoErrors(process(classes, proc)); - assertTrue(proc.generateBootstrap("com.example").contains(".management()")); + assertTrue(proc.generateBootstrap("com.example").contains("cn1Access.management(cn1Builder)")); } @Test - public void settingsAnnotationsBecomeCompiledSettings() throws Exception { - Map s = sample(); - s.put("com.example.Settings", PKG - + "@ServerConfig(port = 8081, workers = 4)\n" - + "@SessionConfig(store = \"DB\", timeoutSeconds = 0, sameSite = \"strict\")\n" - + "@DataSourceConfig(url = \"${DATABASE_URL}\", poolSize = 3)\n" - + "@StaticFilesConfig(root = \"www\", prefix = \"/assets\")\n" - + "public class Settings { }\n"); + public void applicationPropertiesBecomeCompiledSettings() throws Exception { + File classes = compile(sample()); + writeProperties(classes, "application.properties", "cn1.server.port=8081\n" + + "cn1.session.store=db\n" + + "cn1.datasource.url=${DATABASE_URL}\n" + + "greeting.prefix=Hi\n"); + writeProperties(classes, "application-dev.properties", "cn1.datasource.url=:memory:\n"); RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); - assertNoErrors(process(compile(s), proc)); + assertNoErrors(process(classes, proc)); String bootstrap = proc.generateBootstrap("com.example"); for (String pair : new String[] {"\"cn1.server.port\", \"8081\"", - "\"cn1.server.workers\", \"4\"", "\"cn1.session.store\", \"db\"", - "\"cn1.session.timeout\", \"0\"", "\"cn1.session.same-site\", \"Strict\"", + "\"cn1.session.store\", \"db\"", "\"cn1.datasource.url\", \"${DATABASE_URL}\"", - "\"cn1.datasource.pool.size\", \"3\"", "\"cn1.static.root\", \"www\"", - "\"cn1.static.prefix\", \"/assets\""}) { + "\"greeting.prefix\", \"Hi\""}) { assertTrue(pair + " missing from:\n" + bootstrap, bootstrap.contains(pair)); } - assertFalse("an attribute left at its default set a key:\n" + bootstrap, - bootstrap.contains("cn1.server.backlog")); - } - - @Test - public void aSettingTheRuntimeWouldRefuseIsABuildError() throws Exception { - Map s = sample(); - s.put("com.example.Settings", PKG - + "@SessionConfig(store = \"jdbc\") public class Settings { }\n"); - ProcessorContext ctx = process(compile(s)); - assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) - .contains("@SessionConfig store is \"jdbc\"; it must be memory or db.")); - s.put("com.example.Settings", PKG - + "@ServerConfig(port = 70000) public class Settings { }\n"); - ctx = process(compile(s)); - assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) - .contains("@ServerConfig port is 70000; a port is 0 to 65535.")); - s.put("com.example.Settings", PKG - + "@EnableManagement(path = \"ops\") public class Settings { }\n"); - ctx = process(compile(s)); - assertTrue(String.valueOf(ctx.getErrors()), String.valueOf(ctx.getErrors()) - .contains("@EnableManagement path is \"ops\"; a path starts with /.")); + // A profile file describes another deployment; compiling it in would make + // the dev settings a production binary's defaults. + assertFalse("a profile file was compiled in:\n" + bootstrap, bootstrap.contains(":memory:")); } @Test - public void twoClassesSettingOneKeyDifferentlyIsRefused() throws Exception { - Map s = sample(); - s.put("com.example.A", PKG + "@SessionConfig(store = \"db\") public class A { }\n"); - s.put("com.example.B", PKG + "@SessionConfig(store = \"memory\") public class B { }\n"); - ProcessorContext ctx = process(compile(s)); - String errors = String.valueOf(ctx.getErrors()); - assertTrue(errors, errors.contains("cn1.session.store is set to")); - assertTrue(errors, errors.contains("Keep one of them.")); - s.put("com.example.B", PKG + "@SessionConfig(store = \"db\") public class B { }\n"); - assertNoErrors(process(compile(s))); + public void propertiesFilesAreReadAsUtf8AndAProfileFileStandsAlone() throws Exception { + File classes = compile(sample()); + // Only the profile's file: a test on that profile must still get it. + java.nio.file.Files.write(new File(classes, "application-test.properties").toPath(), + "greeting=Gr\u00fc\u00dfe\n".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + ProcessorContext ctx = process(classes, new RestControllerAnnotationProcessor()); + assertNull("there is no base file", RestControllerAnnotationProcessor.baseApplicationProperties(ctx)); + java.util.Properties profile = RestControllerAnnotationProcessor.profileApplicationProperties(ctx, "test"); + assertNotNull("the profile file was not found without a base file", profile); + // Read as UTF-8, as Config reads it at run time, not as ISO-8859-1. + assertEquals("Gr\u00fc\u00dfe", profile.getProperty("greeting")); + java.nio.file.Files.write(new File(classes, "application.properties").toPath(), + "password=p\u00e4ss\n".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertEquals("p\u00e4ss", RestControllerAnnotationProcessor.baseApplicationProperties(ctx) + .getProperty("password")); } @Test @@ -883,7 +866,7 @@ public void aFieldCycleIsFine() throws Exception { public void anInvalidCronExpressionIsABuildError() throws Exception { Map s = new LinkedHashMap(); s.put("com.example.Jobs", PKG - + "@Service public class Jobs {\n" + + "@Component public class Jobs {\n" + " @Scheduled(cron = \"0 0 25 * * *\") public void never() { }\n" + "}\n"); ProcessorContext ctx = process(compile(s)); @@ -895,7 +878,7 @@ public void anInvalidCronExpressionIsABuildError() throws Exception { public void anAsyncMethodMustReturnVoidOrFuture() throws Exception { Map s = new LinkedHashMap(); s.put("com.example.Jobs", PKG - + "@Service public class Jobs {\n" + + "@Component public class Jobs {\n" + " @Async public String now() { return \"x\"; }\n" + "}\n"); ProcessorContext ctx = process(compile(s)); @@ -968,7 +951,7 @@ public void aRequestScopedFactoryBeanIsDestroyedByItsDestroyMethod() throws Exce + " public String use() { return \"used\"; }\n" + "}\n"); s.put("com.example.Handles", PKG + "@Configuration public class Handles {\n" - + " @Bean(destroyMethod = \"close\") @RequestScope\n" + + " @Bean(destroyMethod = \"close\") @Scope(\"request\")\n" + " public Handle handle() { return new Handle(); }\n" + "}\n"); s.put("com.example.Api", PKG @@ -1004,7 +987,7 @@ public void membersInheritedFromABaseClassAreInjected() throws Exception { + " @PostConstruct void baseInit() { inits++; }\n" + " protected String stamp() { return clock.now() + label; }\n" + "}\n"); - s.put("com.example.Orders", PKG + "@Service public class Orders extends BaseService {\n" + s.put("com.example.Orders", PKG + "@Component public class Orders extends BaseService {\n" + " @PostConstruct void ownInit() { inits += 10; }\n" + " public String describe() { return stamp() + inits; }\n" + "}\n"); @@ -1055,7 +1038,7 @@ public void aMissingBeanDefaultStepsAsideForAnotherImplementation() throws Excep @Test public void aSynchronizedAsyncMethodHoldsItsMonitorWhereTheBodyRuns() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Worker", PKG + "@Service public class Worker {\n" + s.put("com.example.Worker", PKG + "@Component public class Worker {\n" + " @Async public synchronized void work() { }\n" + "}\n"); s.put("com.example.Api", PKG @@ -1127,8 +1110,8 @@ public void twoScopedFactoryBeansOfOneClassKeepTheirOwnStandIns() throws Excepti + " public String text() { return text; }\n" + "}\n"); s.put("com.example.Labels", PKG + "@Configuration public class Labels {\n" - + " @Bean @RequestScope public Label east() { return new Label(\"east\"); }\n" - + " @Bean @RequestScope public Label west() { return new Label(\"west\"); }\n" + + " @Bean @Scope(\"request\") public Label east() { return new Label(\"east\"); }\n" + + " @Bean @Scope(\"request\") public Label west() { return new Label(\"west\"); }\n" + "}\n"); s.put("com.example.Api", PKG + "@RestController public class Api {\n" + " @Autowired @Qualifier(\"east\") private Label east;\n" @@ -1149,7 +1132,7 @@ public void twoScopedFactoryBeansOfOneClassKeepTheirOwnStandIns() throws Excepti @Test public void aScopedBeanWhoseConstructorCallsAnOverridableMethodStarts() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Greeter", PKG + "@Component @RequestScope public class Greeter {\n" + s.put("com.example.Greeter", PKG + "@Component @Scope(\"request\") public class Greeter {\n" + " private final String greeting;\n" + " public Greeter() { greeting = prefix() + \" there\"; }\n" + " public String prefix() { return \"hi\"; }\n" @@ -1231,7 +1214,7 @@ public void aWovenOverrideCallingSuperRunsTheBaseBody() throws Exception { @Test public void sessionScopedBeansAreDestroyedWhenTheSessionIsInvalidated() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + s.put("com.example.Cart", PKG + "@Component @Scope(\"session\") public class Cart {\n" + " public static int destroyed;\n" + " private int items;\n" + " public int add() { return ++items; }\n" @@ -1270,7 +1253,7 @@ public void sessionScopedBeansAreDestroyedWhenTheSessionIsInvalidated() throws E } finally { backend.stop(); } - URLClassLoader loader = (URLClassLoader) backend.getApplication().getClass() + URLClassLoader loader = (URLClassLoader) BackendAccess.get().applicationOf(backend).getClass() .getClassLoader(); assertEquals(2, loader.loadClass("com.example.Cart").getField("destroyed").getInt(null)); } @@ -1278,7 +1261,7 @@ public void sessionScopedBeansAreDestroyedWhenTheSessionIsInvalidated() throws E @Test public void aScopedOrOverloadedManagedResourceIsABuildError() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Visits", PKG + "@Component @SessionScope @ManagedResource\n" + s.put("com.example.Visits", PKG + "@Component @Scope(\"session\") @ManagedResource\n" + "public class Visits {\n" + " @ManagedAttribute public int getCount() { return 1; }\n" + "}\n"); @@ -1320,7 +1303,7 @@ public void aFactoryBeanRunsInheritedCallbacksAndDestroysSubclassFirst() throws } finally { backend.stop(); } - Class log = backend.getApplication().getClass().getClassLoader() + Class log = BackendAccess.get().applicationOf(backend).getClass().getClassLoader() .loadClass("com.example.Log"); assertEquals("the subclass must release its state before the base tears down", "open,sub,base,", log.getField("text").get(null)); @@ -1329,10 +1312,10 @@ public void aFactoryBeanRunsInheritedCallbacksAndDestroysSubclassFirst() throws @Test public void aRequestBeanCanUseAnotherWhileItIsDestroyed() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Clock", PKG + "@Component @RequestScope public class Clock {\n" + s.put("com.example.Clock", PKG + "@Component @Scope(\"request\") public class Clock {\n" + " public String now() { return \"t\"; }\n" + "}\n"); - s.put("com.example.Audit", PKG + "@Component @RequestScope public class Audit {\n" + s.put("com.example.Audit", PKG + "@Component @Scope(\"request\") public class Audit {\n" + " public static String last = \"none\";\n" + " @Autowired private Clock clock;\n" + " public void touch() { }\n" @@ -1359,7 +1342,7 @@ public void aRequestBeanCanUseAnotherWhileItIsDestroyed() throws Exception { @Test public void aSessionBeanBuiltByAFailingRequestIsStillDestroyed() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + s.put("com.example.Cart", PKG + "@Component @Scope(\"session\") public class Cart {\n" + " public static int destroyed;\n" + " public void add() { }\n" + " @PreDestroy void close() { destroyed++; }\n" @@ -1383,7 +1366,7 @@ public void aSessionBeanBuiltByAFailingRequestIsStillDestroyed() throws Exceptio } finally { backend.stop(); } - Class cart = backend.getApplication().getClass().getClassLoader() + Class cart = BackendAccess.get().applicationOf(backend).getClass().getClassLoader() .loadClass("com.example.Cart"); assertEquals("a session bean built by a failing request was dropped undestroyed", 1, cart.getField("destroyed").getInt(null)); @@ -1526,12 +1509,12 @@ public void anInactiveControllerIsNotListed() throws Exception { assertNoErrors(process(classes)); URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader + BackendApplication app = (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance(); int port = freePort(); Properties off = new Properties(); off.setProperty(Config.SERVER_PORT, String.valueOf(port)); - Backend backend = Backend.builder(Config.of(off, "dev")).quiet().application(app).start(); + Backend backend = withApplication(Backend.builder(Config.of(off, "dev")).quiet(), app).start(); try { String listed = String.valueOf(app.describeRoutes()); assertTrue(listed, listed.contains("/x")); @@ -1543,7 +1526,7 @@ public void anInactiveControllerIsNotListed() throws Exception { Properties on = new Properties(); on.setProperty(Config.SERVER_PORT, String.valueOf(port)); on.setProperty("admin.on", "true"); - backend = Backend.builder(Config.of(on, "dev")).quiet().application(app).start(); + backend = withApplication(Backend.builder(Config.of(on, "dev")).quiet(), app).start(); try { assertTrue(String.valueOf(app.describeRoutes()).contains("/admin")); } finally { @@ -1604,13 +1587,13 @@ public void aRestartedApplicationForgetsTheBeansOfTheLastStart() throws Exceptio assertNoErrors(process(classes)); URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader + BackendApplication app = (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance(); int port = freePort(); Properties on = new Properties(); on.setProperty(Config.SERVER_PORT, String.valueOf(port)); on.setProperty("feature.x", "true"); - Backend first = Backend.builder(Config.of(on, "dev")).quiet().application(app).start(); + Backend first = withApplication(Backend.builder(Config.of(on, "dev")).quiet(), app).start(); try { assertEquals("true", http("GET", port, "/x")); } finally { @@ -1618,7 +1601,7 @@ public void aRestartedApplicationForgetsTheBeansOfTheLastStart() throws Exceptio } Properties off = new Properties(); off.setProperty(Config.SERVER_PORT, String.valueOf(port)); - Backend second = Backend.builder(Config.of(off, "dev")).quiet().application(app).start(); + Backend second = withApplication(Backend.builder(Config.of(off, "dev")).quiet(), app).start(); try { assertEquals("the second start injected the first start's destroyed bean", "false", http("GET", port, "/x")); @@ -1652,7 +1635,7 @@ public void aScopedBeanForwardsMethodsItInheritsFromALibrary() throws Exception List cp = new ArrayList(backendClasspath()); cp.add(libClasses); Map s = new LinkedHashMap(); - s.put("com.example.Visits", PKG + "@Component @RequestScope\n" + s.put("com.example.Visits", PKG + "@Component @Scope(\"request\")\n" + "public class Visits extends org.lib.BaseCounter { }\n"); s.put("com.example.Api", PKG + "@RestController public class Api {\n" @@ -1675,7 +1658,7 @@ public void aScopedBeanForwardsMethodsItInheritsFromALibrary() throws Exception @Test public void aRequestBeanDestroyedAfterTheHandlerCanStillUseTheSession() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit {\n" + s.put("com.example.Visit", PKG + "@Component @Scope(\"request\") public class Visit {\n" + " @Autowired private HttpServer.Request request;\n" + " public void touch() { }\n" + " @PreDestroy public void done() {\n" @@ -1714,7 +1697,7 @@ public void aRequestBeanDestroyedAfterTheHandlerCanStillUseTheSession() throws E @Test public void aFailedStatementRollsBackAndAnotherCheckedExceptionCommits() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Rows", PKG + "@Service public class Rows {\n" + s.put("com.example.Rows", PKG + "@Component public class Rows {\n" + " private final DataSource db;\n" + " public Rows(DataSource db) { this.db = db; }\n" + " @PostConstruct public void schema() throws java.io.IOException {\n" @@ -1753,9 +1736,8 @@ public void aFailedStatementRollsBackAndAnotherCheckedExceptionCommits() throws getClass().getClassLoader()); Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); - Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() - .requiresDataSource() - .application((Backend.Application) loader + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet() + .requiresDataSource(), (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance()) .start(); try { @@ -1811,15 +1793,14 @@ public void jobsOfSameNamedClassesGetDistinctNames() throws Exception { getClass().getClassLoader()); Properties settings = new Properties(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); - Backend backend = Backend.builder(Config.of(settings, "dev")).quiet() - .application((Backend.Application) loader + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), (BackendApplication) loader .loadClass("com.example.a.BackendWiring").newInstance()) .start(); try { - String jobs = String.valueOf(backend.getApplication().getScheduler().describe()); + String jobs = String.valueOf(BackendAccess.get().applicationOf(backend).getScheduler().describe()); assertTrue(jobs, jobs.contains("com.example.a.Cleanup.run") && jobs.contains("com.example.b.Cleanup.run")); - assertTrue(backend.getApplication().getScheduler() + assertTrue(BackendAccess.get().applicationOf(backend).getScheduler() .trigger("com.example.b.Cleanup.run")); } finally { backend.stop(); @@ -1831,11 +1812,11 @@ public void beansAreDestroyedAfterWhatDependsOnThem() throws Exception { Map s = new LinkedHashMap(); s.put("com.example.Log", PKG + "public class Log { public static String text = \"\"; }\n"); // Aaa is found first and Zzz depends on it: Zzz must go first. - s.put("com.example.Aaa", PKG + "@Component @RequestScope public class Aaa {\n" + s.put("com.example.Aaa", PKG + "@Component @Scope(\"request\") public class Aaa {\n" + " public void use() { }\n" + " @PreDestroy public void done() { Log.text += \"aaa,\"; }\n" + "}\n"); - s.put("com.example.Zzz", PKG + "@Component @RequestScope public class Zzz {\n" + s.put("com.example.Zzz", PKG + "@Component @Scope(\"request\") public class Zzz {\n" + " @Autowired private Aaa aaa;\n" + " public void use() { aaa.use(); }\n" + " @PreDestroy public void done() { Log.text += \"zzz,\"; }\n" @@ -1866,7 +1847,7 @@ public void beansAreDestroyedAfterWhatDependsOnThem() throws Exception { } finally { backend.stop(); } - Class log = backend.getApplication().getClass().getClassLoader() + Class log = BackendAccess.get().applicationOf(backend).getClass().getClassLoader() .loadClass("com.example.Log"); String text = String.valueOf(log.getField("text").get(null)); assertTrue("a lazy bean was destroyed after the eager bean it uses: " + text, @@ -1896,13 +1877,13 @@ public void managedGaugesLeaveWithTheirServer() throws Exception { @Test public void asyncOnARequestBeanOrAScheduledMethodIsABuildError() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit {\n" + s.put("com.example.Visit", PKG + "@Component @Scope(\"request\") public class Visit {\n" + " @Async public void later() { }\n" + "}\n"); s.put("com.example.Jobs", PKG + "@Component public class Jobs {\n" + " @Scheduled(fixedRate = 1000) @Async public void tick() { }\n" + "}\n"); - s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + s.put("com.example.Cart", PKG + "@Component @Scope(\"session\") public class Cart {\n" + " @Async public void recount() { }\n" + "}\n"); s.put("com.example.Stats", PKG + "@Component @ManagedResource(objectName = \"cache/main\")\n" @@ -1913,9 +1894,9 @@ public void asyncOnARequestBeanOrAScheduledMethodIsABuildError() throws Exceptio String errors = String.valueOf(ctx.getErrors()); // Warnings, as Spring runs them: the task calls the instance itself. assertTrue(String.valueOf(warnings), String.valueOf(warnings) - .contains("is on a @RequestScope bean")); + .contains("is on a @Scope(\"request\") bean")); assertTrue(String.valueOf(warnings), String.valueOf(warnings) - .contains("is on a @SessionScope bean")); + .contains("is on a @Scope(\"session\") bean")); assertFalse(errors, errors.contains("is on a @")); assertTrue(errors, errors.contains("is a segment of the management URL")); assertTrue(errors, errors.contains("is also @Async")); @@ -2078,7 +2059,7 @@ public void anInheritedScheduledMethodRuns() throws Exception { assertNoErrors(process(classes)); int port = freePort(); Backend backend = start(classes, port, new Properties()); - Class base = backend.getApplication().getClass().getClassLoader() + Class base = BackendAccess.get().applicationOf(backend).getClass().getClassLoader() .loadClass("com.example.BaseJob"); try { long deadline = System.currentTimeMillis() + 5000; @@ -2106,7 +2087,7 @@ public void aFactoryBuiltBeanKeepsItsScheduledJobs() throws Exception { File classes = compile(s); assertNoErrors(process(classes)); Backend backend = start(classes, freePort(), new Properties()); - Class ticker = backend.getApplication().getClass().getClassLoader() + Class ticker = BackendAccess.get().applicationOf(backend).getClass().getClassLoader() .loadClass("com.example.Ticker"); try { long deadline = System.currentTimeMillis() + 5000; @@ -2137,7 +2118,7 @@ public void twoBeansOfOneClassScheduleTheirJobsSeparately() throws Exception { // Both started: the same job name twice refused the start. Backend backend = start(classes, freePort(), new Properties()); try { - String jobs = String.valueOf(backend.getApplication().getScheduler().describe()); + String jobs = String.valueOf(BackendAccess.get().applicationOf(backend).getScheduler().describe()); assertTrue(jobs, jobs.contains("east.tick") && jobs.contains("west.tick")); } finally { backend.stop(); @@ -2147,7 +2128,7 @@ public void twoBeansOfOneClassScheduleTheirJobsSeparately() throws Exception { @Test public void scopedBeansReachedThroughASingletonAreRefusedOffRequest() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit { }\n"); + s.put("com.example.Visit", PKG + "@Component @Scope(\"request\") public class Visit { }\n"); s.put("com.example.Helper", PKG + "@Component public class Helper {\n" + " @Autowired private Visit visit;\n" + "}\n"); @@ -2181,7 +2162,7 @@ public void scopedBeansReachedThroughASingletonAreRefusedOffRequest() throws Exc @Test public void anAsyncBeanReachingAScopedBeanIsWarned() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit { }\n"); + s.put("com.example.Visit", PKG + "@Component @Scope(\"request\") public class Visit { }\n"); s.put("com.example.Helper", PKG + "@Component public class Helper {\n" + " @Autowired private Visit visit;\n" + "}\n"); @@ -2218,24 +2199,24 @@ public void aScopedBeanInheritingAnAsyncMethodIsWarned() throws Exception { s.put("com.example.Worker", PKG + "public abstract class Worker {\n" + " @Async public void later() { }\n" + "}\n"); - s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit " + s.put("com.example.Visit", PKG + "@Component @Scope(\"request\") public class Visit " + "extends Worker { }\n"); assertNoErrors(process(compile(s))); String warned = String.valueOf(warnings); assertTrue(warned, warned.contains("@Async method com.example.Visit.later is on a " - + "@RequestScope bean")); + + "@Scope(\"request\") bean")); } @Test public void aScopedConfigurationCannotBuildASingleton() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.PerRequest", PKG + "@Configuration @RequestScope public class PerRequest {\n" + s.put("com.example.PerRequest", PKG + "@Configuration @Scope(\"request\") public class PerRequest {\n" + " @Bean public StringBuilder buffer() { return new StringBuilder(); }\n" + "}\n"); String errors = String.valueOf(process(compile(s)).getErrors()); assertTrue(errors, errors.contains("@Bean method com.example.PerRequest.buffer builds a " + "singleton bean")); - s.put("com.example.PerRequest", PKG + "@Configuration @RequestScope public class PerRequest {\n" + s.put("com.example.PerRequest", PKG + "@Configuration @Scope(\"request\") public class PerRequest {\n" + " @Bean public static StringBuilder buffer() { return new StringBuilder(); }\n" + "}\n"); assertNoErrors(process(compile(s))); @@ -2282,7 +2263,7 @@ public void aPrototypeControllerIsBuiltOnce() throws Exception { assertNoErrors(process(classes)); Backend backend = start(classes, freePort(), new Properties()); try { - Class api = backend.getApplication().getClass().getClassLoader() + Class api = BackendAccess.get().applicationOf(backend).getClass().getClassLoader() .loadClass("com.example.Api"); assertEquals("the router's controller was built twice, one discarded", 1, ((java.util.concurrent.atomic.AtomicInteger) api.getField("BUILT").get(null)) @@ -2319,10 +2300,10 @@ public void aFactoryReturningNullStopsTheStart() throws Exception { @Test public void sessionBeansAndSocketsCannotHoldRequestState() throws Exception { Map s = new LinkedHashMap(); - s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + s.put("com.example.Cart", PKG + "@Component @Scope(\"session\") public class Cart {\n" + " @Autowired private HttpSession session;\n" + "}\n"); - s.put("com.example.Visit", PKG + "@Component @RequestScope public class Visit { }\n"); + s.put("com.example.Visit", PKG + "@Component @Scope(\"request\") public class Visit { }\n"); s.put("com.example.Chat", PKG + "@WebSocketMapping(\"/chat\")\n" + "public class Chat implements WebSocket {\n" + " @Autowired private Visit visit;\n" @@ -2359,7 +2340,7 @@ public void everyScopeAndBindingWorksAtRunTime() throws Exception { + " @PostConstruct void warm() { built++; }\n" + " public String hello() { return \"lazy\"; }\n" + "}\n"); - s.put("com.example.Cart", PKG + "@Component @SessionScope public class Cart {\n" + s.put("com.example.Cart", PKG + "@Component @Scope(\"session\") public class Cart {\n" + " private int items;\n" + " public int add() { return ++items; }\n" + "}\n"); @@ -2447,9 +2428,9 @@ public void theCronCompilerAgreesWithTheRuntimeParser() throws Exception { "0 0 12 ? * SUN", "0 0 0 * * 7"}; for (String e : expressions) { CronCompiler built = CronCompiler.compile(e); - com.codename1.backend.CronSchedule runtime = - com.codename1.backend.CronSchedule.parse(e, "UTC"); - com.codename1.backend.CronSchedule fromMasks = new com.codename1.backend.CronSchedule( + com.codename1.impl.backend.CronSchedule runtime = + com.codename1.impl.backend.CronSchedule.parse(e, "UTC"); + com.codename1.impl.backend.CronSchedule fromMasks = new com.codename1.impl.backend.CronSchedule( built.seconds, built.minutes, built.hours, built.daysOfMonth, built.months, built.daysOfWeek, built.lastDayOfMonth, "UTC", e); long t = 1767225600000L; // 2026-01-01T00:00:00Z @@ -2467,10 +2448,10 @@ public void theCronCompilerAgreesWithTheRuntimeParser() throws Exception { private Backend start(File classes, int port, Properties settings) throws Exception { URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, getClass().getClassLoader()); - Backend.Application app = (Backend.Application) loader + BackendApplication app = (BackendApplication) loader .loadClass("com.example.BackendWiring").newInstance(); settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); - return Backend.builder(Config.of(settings, "dev")).quiet().application(app).start(); + return withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), app).start(); } private File compile(Map sources) throws Exception { @@ -2539,6 +2520,76 @@ private static int freePort() throws Exception { } } + @Test + public void multipartPartsAndFormFieldsBindToParameters() throws Exception { + Map s = new LinkedHashMap(); + s.put("com.example.Uploads", PKG + + "@RestController public class Uploads {\n" + + " @PostMapping(\"/upload\")\n" + + " public String upload(@RequestPart(\"file\") HttpServer.Part file,\n" + + " @RequestPart(\"raw\") byte[] raw, @RequestPart(\"note\") String note,\n" + + " @RequestParam(\"title\") String title,\n" + + " @RequestPart(value = \"extra\", required = false) String extra) {\n" + + " return file.getFilename() + \":\" + file.getSize() + \":\" + raw.length\n" + + " + \":\" + note + \":\" + title + \":\" + extra;\n" + + " }\n" + + " @PostMapping(\"/form\")\n" + + " public String form(@RequestParam(\"a\") String a, @RequestParam(\"n\") int n) {\n" + + " return a + \"/\" + n;\n" + + " }\n" + + "}\n"); + File classes = compile(s); + assertNoErrors(process(classes)); + URLClassLoader loader = new URLClassLoader(new URL[] {classes.toURI().toURL()}, + getClass().getClassLoader()); + BackendApplication app = (BackendApplication) loader + .loadClass("com.example.BackendWiring").newInstance(); + int port = freePort(); + Properties settings = new Properties(); + settings.setProperty(Config.SERVER_PORT, String.valueOf(port)); + Backend backend = withApplication(Backend.builder(Config.of(settings, "dev")).quiet(), app) + .start(); + try { + String boundary = "b0undary"; + String body = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"title\"\r\n\r\nHello\r\n" + + "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"note\"\r\n\r\nshort\r\n" + + "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"raw\"\r\n\r\nabc\r\n" + + "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n" + + "Content-Type: text/plain\r\n\r\n12345\r\n" + + "--" + boundary + "--\r\n"; + String type = "multipart/form-data; boundary=" + boundary; + assertEquals("a.txt:5:3:short:Hello:null", send(port, "/upload", type, body)); + // A missing required part, and a body that is not multipart at all. + String partial = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"title\"\r\n\r\nHello\r\n" + + "--" + boundary + "--\r\n"; + assertTrue(send(port, "/upload", type, partial).startsWith("HTTP 400")); + assertTrue(send(port, "/upload", type, "--" + boundary + "\r\nbroken") + .startsWith("HTTP 400")); + // A urlencoded form is @RequestParam too, converted like a query value. + assertEquals("x y/7", send(port, "/form", "application/x-www-form-urlencoded", + "a=x+y&n=7")); + } finally { + backend.stop(); + } + } + + private static String send(int port, String path, String type, String body) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) + .openConnection(); + c.setRequestMethod("POST"); + c.setDoOutput(true); + c.setRequestProperty("Content-Type", type); + OutputStream out = c.getOutputStream(); + out.write(body.getBytes("UTF-8")); + out.close(); + return read(c); + } + private static String http(String method, int port, String path) throws Exception { HttpURLConnection c = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path) .openConnection(); @@ -2579,4 +2630,32 @@ private static String read(HttpURLConnection c) throws Exception { } return body; } + + // The build's own builder calls go through the runtime's internal access, + // as the generated entry point does; these keep the tests' chains readable. + private static Backend.Builder withApplication(Backend.Builder builder, + BackendApplication application) { + BackendAccess.get().application(builder, application); + return builder; + } + + private static Backend.Builder withMcp(Backend.Builder builder, + com.codename1.impl.backend.mcp.McpServer.Extension devTools) { + BackendAccess.get().mcp(builder, devTools); + return builder; + } + + private static Backend.Builder withManagement(Backend.Builder builder) { + BackendAccess.get().management(builder); + return builder; + } + + private static void writeProperties(File dir, String name, String content) throws IOException { + java.io.FileWriter w = new java.io.FileWriter(new File(dir, name)); + try { + w.write(content); + } finally { + w.close(); + } + } } diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java new file mode 100644 index 00000000000..a2e97d9d325 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java @@ -0,0 +1,545 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven.processors; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.test.MockMvc; +import com.codename1.impl.backend.test.TestContext; +import com.codename1.impl.backend.test.TestContexts; +import com.codename1.impl.backend.test.TestEnvironment; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; +import com.codename1.build.BuildFailureException; +import com.codename1.build.SystemStreamLog; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// The test pass end to end on the JVM: an application and a `@BackendTest` +/// class compiled, the pass run over them, and the generated context started and +/// used the way the JUnit extension uses it. +public class BackendTestGeneratorTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String MAIN = "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n"; + + @After + public void stopTheApplication() { + TestContexts.shutdown(); + } + + private static Map application() { + Map s = new LinkedHashMap(); + s.put("com.example.Clock", MAIN + "public interface Clock { long now(); }\n"); + s.put("com.example.SystemClock", MAIN + "@Component public class SystemClock implements Clock {\n" + + " public long now() { return System.currentTimeMillis(); }\n}\n"); + s.put("com.example.Greeter", MAIN + "@Component public class Greeter {\n" + + " public String greet(String name) { return \"Hello, \" + name; }\n}\n"); + s.put("com.example.Api", MAIN + "@RestController public class Api {\n" + + " private final Greeter greeter;\n" + + " private final Clock clock;\n" + + " public Api(Greeter greeter, Clock clock) { this.greeter = greeter; this.clock = clock; }\n" + + " @GetMapping(\"/greet/{name}\") public String greet(@PathVariable(\"name\") String name) {\n" + + " return greeter.greet(name);\n }\n" + + " @GetMapping(\"/now\") public String now() { return String.valueOf(clock.now()); }\n" + + "}\n"); + return s; + } + + @Test + public void aGeneratedContextStartsTheApplicationWithTheTestsBeans() throws Exception { + File classes = mainBuild(application()); + Map t = new LinkedHashMap(); + t.put("com.example.ApiTest", "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "import static com.codename1.backend.test.MockMvcRequestBuilders.*;\n" + + "import static com.codename1.backend.test.MockMvcResultMatchers.*;\n" + + "@BackendTest(properties = \"greeting.mode=test\")\n" + + "public class ApiTest {\n" + + " @Autowired private MockMvc mvc;\n" + + " @Autowired private Greeter greeter;\n" + + " @LocalServerPort private int port;\n" + + " @TestConfiguration\n" + + " static class Fakes {\n" + + " @Bean @Primary public Clock fixedClock() {\n" + + " return new Clock() { public long now() { return 42; } };\n" + + " }\n" + + " }\n" + + " @org.junit.jupiter.api.Test\n" + + " void greets() throws Exception {\n" + + " mvc.perform(get(\"/greet/{name}\", \"Ada Lovelace\"))\n" + + " .andExpect(status().isOk())\n" + + " .andExpect(content().string(\"Hello, Ada Lovelace\"));\n" + + " mvc.perform(get(\"/now\")).andExpect(content().string(\"42\"));\n" + + " mvc.perform(get(\"/missing\")).andExpect(status().isNotFound());\n" + + " if (!\"Hello, Bo\".equals(greeter.greet(\"Bo\"))) throw new AssertionError(\"greeter\");\n" + + " if (port <= 0) throw new AssertionError(\"port \" + port);\n" + + " }\n" + + " @org.junit.jupiter.api.Test\n" + + " void failsLoudly() throws Exception {\n" + + " mvc.perform(get(\"/now\")).andExpect(content().string(\"43\"));\n" + + " }\n" + + "}\n"); + File tests = testBuild(classes, t); + URLClassLoader loader = new URLClassLoader(new URL[] {tests.toURI().toURL(), + classes.toURI().toURL()}, getClass().getClassLoader()); + Object context = loader.loadClass("com.example.ApiTestCn1TestContext").newInstance(); + assertTrue(context instanceof TestContext); + TestEnvironment env = TestContexts.acquire((TestContext) context); + Object test = loader.loadClass("com.example.ApiTest").newInstance(); + ((TestContext) context).inject(test, env); + invoke(test, "greets"); + try { + invoke(test, "failsLoudly"); + fail("a failed expectation passed"); + } catch (AssertionError expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("expected:<43> but was:<42>")); + } + // A second class with the same configuration gets the same application. + assertTrue(env == TestContexts.acquire((TestContext) loader + .loadClass("com.example.ApiTestCn1TestContext").newInstance())); + } + + @Test + public void anInheritedBackendTestGivesEachSubclassItsContext() throws Exception { + // As @SpringBootTest is: the annotation, the injected fields and the tests + // on an abstract base; the concrete subclass is what runs. + File classes = mainBuild(application()); + Map t = new LinkedHashMap(); + t.put("com.example.AbstractApiTest", "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "import static com.codename1.backend.test.MockMvcRequestBuilders.*;\n" + + "import static com.codename1.backend.test.MockMvcResultMatchers.*;\n" + + "@BackendTest\n" + + "public abstract class AbstractApiTest {\n" + + " @Autowired protected MockMvc mvc;\n" + + " @org.junit.jupiter.api.Test\n" + + " void greets() throws Exception {\n" + + " mvc.perform(get(\"/greet/{name}\", \"Ada\")).andExpect(content().string(\"Hello, Ada\"));\n" + + " }\n" + + "}\n"); + t.put("com.example.ConcreteApiTest", "package com.example;\n" + + "public class ConcreteApiTest extends AbstractApiTest {\n" + + "}\n"); + File tests = testBuild(classes, t); + assertFalse("the abstract base got a context of its own", + new File(tests, "com/example/AbstractApiTestCn1TestContext.class").isFile()); + URLClassLoader loader = new URLClassLoader(new URL[] {tests.toURI().toURL(), + classes.toURI().toURL()}, getClass().getClassLoader()); + try { + Object context = loader.loadClass("com.example.ConcreteApiTestCn1TestContext").newInstance(); + TestEnvironment env = TestContexts.acquire((TestContext) context); + Object test = loader.loadClass("com.example.ConcreteApiTest").newInstance(); + ((TestContext) context).inject(test, env); + java.lang.reflect.Method greets = loader.loadClass("com.example.AbstractApiTest") + .getDeclaredMethod("greets"); + greets.setAccessible(true); + greets.invoke(test); + } finally { + loader.close(); + } + } + + @Test + public void malformedInputToGeneratedBindingsIsA400() throws Exception { + // A binary body that is not UTF-8 for a @RequestBody String, and a malformed + // multipart body behind an optional @RequestParam: the client's errors, so + // 400s from the generated guards rather than 500s from the argument list or + // a handler run on a body nothing parsed. + Map app = application(); + app.put("com.example.Inputs", MAIN + "@RestController public class Inputs {\n" + + " @PostMapping(\"/text\") public String text(@RequestBody String body) {\n" + + " return body;\n }\n" + + " @PostMapping(\"/form\") public String form(\n" + + " @RequestParam(value = \"a\", required = false) String a) {\n" + + " return \"ran:\" + a;\n }\n" + + "}\n"); + File classes = mainBuild(app); + Map t = new LinkedHashMap(); + t.put("com.example.InputsTest", "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "import static com.codename1.backend.test.MockMvcRequestBuilders.*;\n" + + "import static com.codename1.backend.test.MockMvcResultMatchers.*;\n" + + "@BackendTest\n" + + "public class InputsTest {\n" + + " @Autowired MockMvc mvc;\n" + + " void check() throws Exception {\n" + + " mvc.perform(post(\"/text\").contentType(\"application/octet-stream\")\n" + + " .content(new byte[] {(byte) 0xff, (byte) 0xfe}))\n" + + " .andExpect(status().isBadRequest());\n" + + " mvc.perform(post(\"/text\").contentType(\"application/octet-stream\")\n" + + " .content(\"plain\")).andExpect(content().string(\"plain\"));\n" + + " mvc.perform(post(\"/form\").contentType(\"multipart/form-data\")\n" + + " .content(\"--x\\r\\n\\r\\nhi\\r\\n--x--\"))\n" + + " .andExpect(status().isBadRequest());\n" + + " mvc.perform(post(\"/form\").param(\"a\", \"1\"))\n" + + " .andExpect(content().string(\"ran:1\"));\n" + + " }\n" + + "}\n"); + File tests = testBuild(classes, t); + URLClassLoader loader = new URLClassLoader(new URL[] {tests.toURI().toURL(), + classes.toURI().toURL()}, getClass().getClassLoader()); + try { + Object context = loader.loadClass("com.example.InputsTestCn1TestContext").newInstance(); + TestEnvironment env = TestContexts.acquire((TestContext) context); + Object test = loader.loadClass("com.example.InputsTest").newInstance(); + ((TestContext) context).inject(test, env); + invoke(test, "check"); + } finally { + loader.close(); + } + } + + @Test + public void onlyADirectlyNestedTestConfigurationAppliesToItsTest() throws Exception { + // Helper.Fakes is nested one level deeper; it must not join OuterTest's + // context, where its second Clock would make the injection ambiguous. + File classes = mainBuild(application()); + Map t = new LinkedHashMap(); + t.put("com.example.OuterTest", "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "@BackendTest\n" + + "public class OuterTest {\n" + + " @Autowired Clock clock;\n" + + " static class Helper {\n" + + " @TestConfiguration\n" + + " static class Fakes {\n" + + " @Bean public Clock other() { return null; }\n" + + " }\n" + + " }\n" + + "}\n"); + File tests = testBuild(classes, t); + assertTrue("the outer test's context was not generated", + new File(tests, "com/example/OuterTestCn1TestContext.class").isFile()); + } + + @Test + public void testsSplitOverTwoOutputDirectoriesAreOneHierarchy() throws Exception { + // A mixed Java and Kotlin Gradle test set: the @BackendTest base in one + // compiler's output, the concrete test in the other's. The base's private + // injected field is woven where its class file is. + File classes = mainBuild(application()); + List cp = new ArrayList(classpath()); + cp.add(0, classes); + File javaOut = tmp.newFolder(); + Map base = new LinkedHashMap(); + base.put("com.example.AbstractServedTest", "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "import static com.codename1.backend.test.MockMvcRequestBuilders.*;\n" + + "import static com.codename1.backend.test.MockMvcResultMatchers.*;\n" + + "@BackendTest\n" + + "public abstract class AbstractServedTest {\n" + + " @Autowired private MockMvc mvc;\n" + + " public void greets() throws Exception {\n" + + " mvc.perform(get(\"/greet/{name}\", \"Bo\")).andExpect(content().string(\"Hello, Bo\"));\n" + + " }\n" + + "}\n"); + JavaSourceCompiler.compile(base, javaOut, cp); + File kotlinOut = tmp.newFolder(); + Map sub = new LinkedHashMap(); + sub.put("com.example.ConcreteServedTest", "package com.example;\n" + + "public class ConcreteServedTest extends AbstractServedTest {\n" + + "}\n"); + List subCp = new ArrayList(cp); + subCp.add(0, javaOut); + JavaSourceCompiler.compile(sub, kotlinOut, subCp); + List elements = new ArrayList(); + elements.add(javaOut.getAbsolutePath()); + for (File f : cp) { + elements.add(f.getAbsolutePath()); + } + BackendTests.process(Collections.singletonList(classes), kotlinOut, + Collections.singletonList(javaOut), tmp.newFolder(), tmp.newFolder(), + Collections.emptyList(), "UTF-8", elements, false, null, new SystemStreamLog()); + assertTrue("the concrete test in the other directory got no context", + new File(kotlinOut, "com/example/ConcreteServedTestCn1TestContext.class").isFile()); + URLClassLoader loader = new URLClassLoader(new URL[] {kotlinOut.toURI().toURL(), + javaOut.toURI().toURL(), classes.toURI().toURL()}, getClass().getClassLoader()); + try { + Object context = loader.loadClass("com.example.ConcreteServedTestCn1TestContext").newInstance(); + TestEnvironment env = TestContexts.acquire((TestContext) context); + Object test = loader.loadClass("com.example.ConcreteServedTest").newInstance(); + ((TestContext) context).inject(test, env); + loader.loadClass("com.example.AbstractServedTest").getMethod("greets").invoke(test); + } finally { + loader.close(); + } + } + + @Test + public void aTestThatReturnsAValueIsABuildError() throws Exception { + Map t = new LinkedHashMap(); + t.put("t.ValueTest", "package t;\n" + + "public class ValueTest {\n" + + " @org.junit.jupiter.api.Test int counts() { return 1; }\n" + + "}\n"); + File tests = tmp.newFolder(); + List cp = new ArrayList(classpath()); + JavaSourceCompiler.compile(t, tests, cp); + List elements = new ArrayList(); + elements.add(tests.getAbsolutePath()); + for (File f : cp) { + elements.add(f.getAbsolutePath()); + } + try { + BackendTests.process(tmp.newFolder(), tests, tmp.newFolder(), tmp.newFolder(), + Collections.emptyList(), "UTF-8", elements, true, new SystemStreamLog()); + fail("a value-returning test was accepted for the compiled run"); + } catch (BuildFailureException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("returns a value")); + } + } + + @Test + public void anAmbiguousInjectionIsABuildError() throws Exception { + File classes = mainBuild(application()); + Map t = new LinkedHashMap(); + t.put("com.example.BadTest", "package com.example;\n" + + "import com.codename1.backend.annotations.*;\n" + + "import com.codename1.backend.test.*;\n" + + "@BackendTest\n" + + "public class BadTest {\n" + + " @Autowired Clock clock;\n" + + " @TestConfiguration\n" + + " static class Fakes {\n" + + " @Bean public Clock other() { return null; }\n" + + " }\n" + + "}\n"); + try { + testBuild(classes, t); + fail("an injection two beans could fill was accepted"); + } catch (BuildFailureException expected) { + // The application's own constructor injection is where it shows first: + // the test bean is a second Clock, and neither is @Primary. + assertTrue(expected.getMessage(), expected.getMessage().contains( + "could receive any of systemClock (com.example.SystemClock), other " + + "(com.example.Clock)")); + } + } + + @Test + public void theCompiledRunnerRunsWhatJUnitWouldAndCanReachIt() throws Exception { + // A base class in another package: its package-private test and lifecycle + // method are reached through a generated helper in that package, and a + // test the subclass overrides without @Test is not a test any more. + Map t = new LinkedHashMap(); + t.put("base.Base", "package base;\n" + + "import org.junit.jupiter.api.*;\n" + + "public abstract class Base {\n" + + " public static java.util.List calls = new java.util.ArrayList();\n" + + " @BeforeEach void setUp() { calls.add(\"setUp\"); }\n" + + " @Test void inherited() { calls.add(\"inherited\"); }\n" + + " @Test public void overridden() { calls.add(\"base-overridden\"); }\n" + + " @Test void packagePrivate() { calls.add(\"base-package-private\"); }\n" + + "}\n"); + t.put("t.Checks", "package t;\n" + + "public interface Checks {\n" + + " @org.junit.jupiter.api.Test default void fromInterface() {\n" + + " base.Base.calls.add(\"interface\");\n" + + " }\n" + + "}\n"); + t.put("t.Fast", "package t;\n" + + "@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)\n" + + "@java.lang.annotation.Target(java.lang.annotation.ElementType.METHOD)\n" + + "@org.junit.jupiter.api.Test\n" + + "public @interface Fast {\n" + + "}\n"); + t.put("t.Skipped", "package t;\n" + + "@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)\n" + + "@java.lang.annotation.Target(java.lang.annotation.ElementType.METHOD)\n" + + "@org.junit.jupiter.api.Test\n" + + "@org.junit.jupiter.api.Disabled(\"not on this target\")\n" + + "public @interface Skipped {\n" + + "}\n"); + t.put("t.InterfaceOnlyTest", "package t;\n" + + "public class InterfaceOnlyTest implements Checks {\n" + + "}\n"); + t.put("t.GatedTest", "package t;\n" + + "public class GatedTest {\n" + + " @org.junit.jupiter.api.BeforeAll static void gate() {\n" + + " org.junit.jupiter.api.Assumptions.assumeTrue(false, \"no database here\");\n" + + " }\n" + + " @org.junit.jupiter.api.Test void gated() { base.Base.calls.add(\"gated\"); }\n" + + "}\n"); + t.put("t.SubTest", "package t;\n" + + "public class SubTest extends base.Base {\n" + + " @Override public void overridden() { calls.add(\"not-a-test\"); }\n" + // Not an override: Base.packagePrivate is package-private in another + // package, so JUnit still runs the base test. + + " void packagePrivate() { calls.add(\"sub-package-private\"); }\n" + + " @org.junit.jupiter.api.Test void own() { calls.add(\"own\"); }\n" + + " @Fast void composed() { calls.add(\"composed\"); }\n" + + " @Skipped void composedDisabled() { calls.add(\"ran-disabled\"); }\n" + + "}\n"); + File classes = tmp.newFolder(); + File tests = tmp.newFolder(); + List cp = new ArrayList(classpath()); + JavaSourceCompiler.compile(t, tests, cp); + List elements = new ArrayList(); + elements.add(tests.getAbsolutePath()); + for (File f : cp) { + elements.add(f.getAbsolutePath()); + } + BackendTests.process(classes, tests, tmp.newFolder(), tmp.newFolder(), + Collections.emptyList(), "UTF-8", elements, true, new SystemStreamLog()); + assertTrue("a helper was generated in the base class's package", + new File(tests, "base/BaseCn1TestAccess.class").isFile()); + List urls = new ArrayList(); + urls.add(tests.toURI().toURL()); + java.net.URLClassLoader loader = new java.net.URLClassLoader( + urls.toArray(new java.net.URL[0]), getClass().getClassLoader()); + try { + loader.loadClass("t.SubTestCn1TestRunner").getMethod("run").invoke(null); + loader.loadClass("t.InterfaceOnlyTestCn1TestRunner").getMethod("run").invoke(null); + java.lang.reflect.Field failed = com.codename1.impl.backend.test.TestRun.class + .getDeclaredField("failed"); + failed.setAccessible(true); + int failuresBefore = failed.getInt(null); + loader.loadClass("t.GatedTestCn1TestRunner").getMethod("run").invoke(null); + assertEquals("an @BeforeAll assumption that does not hold is a skip, not a failure", + failuresBefore, failed.getInt(null)); + @SuppressWarnings("unchecked") + List calls = (List) loader.loadClass("base.Base").getField("calls").get(null); + assertTrue(calls.toString(), calls.contains("inherited")); + assertTrue(calls.toString(), calls.contains("own")); + assertTrue(calls.toString(), calls.contains("setUp")); + assertFalse("an override without @Test ran as a test: " + calls, + calls.contains("not-a-test") || calls.contains("base-overridden")); + assertFalse("a test ran although its @BeforeAll aborted: " + calls, calls.contains("gated")); + assertTrue("a package-private base test a same-signature method in another package " + + "does not override was dropped: " + calls, calls.contains("base-package-private")); + assertTrue("a test inherited from an interface did not run: " + calls, + calls.contains("interface")); + assertTrue("a test marked with a composed @Test annotation did not run: " + calls, + calls.contains("composed")); + assertFalse("a test disabled through a composed annotation ran: " + calls, + calls.contains("ran-disabled")); + } finally { + loader.close(); + } + } + + private static void invoke(Object test, String name) throws Exception { + java.lang.reflect.Method m = test.getClass().getDeclaredMethod(name); + m.setAccessible(true); + try { + m.invoke(test); + } catch (InvocationTargetException err) { + if (err.getCause() instanceof Exception) { + throw (Exception) err.getCause(); + } + if (err.getCause() instanceof Error) { + throw (Error) err.getCause(); + } + throw err; + } + } + + /// Compiles and processes the application the way process-annotations does, + /// wiring record included. + private File mainBuild(Map sources) throws Exception { + File classes = tmp.newFolder(); + JavaSourceCompiler.compile(sources, classes, classpath()); + Map index = ClassScanner.scan(classes); + List cp = new ArrayList(); + for (File f : classpath()) { + cp.add(f.getAbsolutePath()); + } + ProcessorContext ctx = new ProcessorContext(classes, tmp.newFolder(), index, + new SystemStreamLog(), tmp.newFolder(), new Properties(), null, + Collections.emptyList(), "UTF-8", cp); + BackendBeanAnnotationProcessor beans = new BackendBeanAnnotationProcessor(); + RestControllerAnnotationProcessor proc = new RestControllerAnnotationProcessor(); + beans.start(ctx); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + proc.processClass(cls, ctx); + } + } + beans.finish(ctx); + proc.finish(ctx); + if (ctx.hasErrors()) { + fail("the processors reported errors: " + ctx.getErrors()); + } + for (Map.Entry e : ctx.getEmittedResources().entrySet()) { + File f = new File(classes, e.getKey()); + f.getParentFile().mkdirs(); + Files.write(f.toPath(), e.getValue()); + } + return classes; + } + + private File testBuild(File classes, Map sources) throws Exception { + File tests = tmp.newFolder(); + List cp = new ArrayList(classpath()); + cp.add(0, classes); + JavaSourceCompiler.compile(sources, tests, cp); + List elements = new ArrayList(); + for (File f : cp) { + elements.add(f.getAbsolutePath()); + } + BackendTests.process(classes, tests, tmp.newFolder(), tmp.newFolder(), + Collections.emptyList(), "UTF-8", elements, false, new SystemStreamLog()); + return tests; + } + + private static List classpath() throws Exception { + List out = new ArrayList(); + out.add(jarOf(HttpServer.class)); + out.add(jarOf(MockMvc.class)); + out.add(jarOf(org.junit.jupiter.api.Test.class)); + return out; + } + + private static File jarOf(Class c) throws Exception { + return new File(c.getProtectionDomain().getCodeSource().getLocation().toURI()); + } +} diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml index 66a8f12b38f..cb4163a2c06 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml +++ b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -309,6 +309,12 @@ **/*.java + + src/test/java + + **/*.java + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + @@ -80,8 +101,38 @@ process-annotations + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java index 4c196509d6e..d330f234810 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/main/java/Greeter.java @@ -22,12 +22,12 @@ */ package ${package}; -import com.codename1.backend.annotations.Service; +import com.codename1.backend.annotations.Component; /** * A service: business logic with no HTTP in it, which the controller is given. * - * The build finds every @Service, @Component and @Repository, works out + * The build finds every @Component, works out * what each one's constructor needs, and writes the `new` calls into the entry * point it generates -- so this reads like Spring, and there is no container, no * reflection and no start-up scan once it runs. A missing or ambiguous dependency @@ -38,7 +38,7 @@ * @McpTool methods an agent can call. See the Backend chapter of the developer * guide, or the backend reference in this project's agent skill. */ -@Service +@Component public class Greeter { /** Greets someone. */ public String greet(String name) { diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java new file mode 100644 index 00000000000..e8632393ce2 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package ${package}; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.content; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +/** + * Tests for Api, in the shape of Spring Boot's: the annotations and the MockMvc + * calls are Spring's, under Codename One's package names. + * + * @BackendTest starts this module's server, wired exactly as it ships, and + * injects what the test asks for. MockMvc dispatches a request in process, + * without a socket; TestRestTemplate (with a RANDOM_PORT web environment) sends + * it over a real port instead, as ServedApiTest does. Run them with + * + * mvn -pl backend -Dcodename1.platform=backend test + * + * and add -Dcn1.backend.compiledTests=true to also translate the same tests into + * a native test binary and run that, which is how the server ships. + */ +@BackendTest +class ApiTest { + @Autowired + private MockMvc mvc; + + @Test + void greetsThroughTheInjectedService() throws Exception { + mvc.perform(get("/greet/{name}", "Ada")) + .andExpect(status().isOk()) + .andExpect(content().string("Hello, Ada")); + } + + @Test + void echoAnswersJson() throws Exception { + mvc.perform(get("/echo").param("say", "hi")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.say").value("hi")); + mvc.perform(get("/echo")).andExpect(jsonPath("$.say").value("hello")); + } +} diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java new file mode 100644 index 00000000000..5fb729867c2 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package ${package}; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.HttpStatus; +import com.codename1.backend.test.TestRestTemplate; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The same server on a real port, reached through the backend's own HTTP client: + * the request goes through the socket, the parser and the response writer that a + * deployed server uses. The port is chosen at random, so a server already running + * on 8080 does not get in the way. + */ +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class ServedApiTest { + @Autowired + private TestRestTemplate rest; + + @Test + void answersOverTheNetwork() throws Exception { + assertEquals("ok", rest.getForObject("/healthz", String.class)); + assertEquals("Hello, Ada", rest.getForObject("/greet/{name}", String.class, "Ada")); + assertEquals(HttpStatus.NOT_FOUND, rest.getForEntity("/nowhere", String.class).getStatusCode()); + } +} diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml index 6c77f19a20f..fb0e34fb0b1 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml @@ -119,23 +119,6 @@ codenameone-maven-plugin ${cn1.plugin.version} - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.0.0-M5 - - - com.codenameone - codenameone-maven-plugin - ${cn1.plugin.version} - - - @@ -178,6 +161,43 @@ common/pom.xml). Maven requires both the property and the file. --> + + + cn1-client-tests + + + codename1.platform + !backend + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + javascript diff --git a/maven/codenameone-gradle-plugin/spotbugs-exclude.xml b/maven/codenameone-gradle-plugin/spotbugs-exclude.xml index c1fdf419c4e..535c31de88d 100644 --- a/maven/codenameone-gradle-plugin/spotbugs-exclude.xml +++ b/maven/codenameone-gradle-plugin/spotbugs-exclude.xml @@ -23,6 +23,20 @@ + + + + + + + + + + + + diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java index 1b59153407a..9d34373da25 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendPackageSupport.java @@ -66,40 +66,71 @@ static void register(Project project, ProjectLayout layout, SourceSet main, Code project.getDependencies().addProvider(runtime.getName(), ext.getVersion().map(v -> PluginInfo.GROUP + ":codenameone-parparvm:" + v + ":bundle")); project.getTasks().register("backendPackage", PackageTask.class, t -> { - AppSupport.common(t, project, layout, ext, project.provider(Collections::emptyMap)); + configure(t, project, layout, main, ext, runtime); t.setDescription("Builds the backend as a single native binary"); - t.dependsOn(main.getClassesTaskName()); - t.getToolchain().from(runtime); - // The compile classpath, as Maven's backend-package resolves (scope - // compile), not the runtime one: the binary is translated by ParparVM, - // which loads nothing by name (Class.forName is banned in the backend), - // so a runtimeOnly library -- a JDBC driver, say -- could not run in it. - t.getCompileClasspath().from(main.getCompileClasspath()); - t.getArtifacts().set(project.getConfigurations().getByName(main.getCompileClasspathConfigurationName()) - .getIncoming().artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts() - .map(set -> AppSupport.encode(set, "compile"))); - t.getSources().from(main.getJava().getSrcDirs()); - // What annotation processors (annotationProcessor(...)) generated: the - // packager compiles the sources again, without javac's processor path, - // so it needs their output the way runBackend has it from the classes. - t.getSources().from(project.getTasks().named(main.getCompileJavaTaskName(), - org.gradle.api.tasks.compile.JavaCompile.class) - .flatMap(c -> c.getOptions().getGeneratedSourceOutputDirectory())); - t.getSourceEncoding().set(AppSupport.javaEncoding(project, main)); - t.getProcessedResources().from(project.provider(() -> main.getOutput().getResourcesDir())); - t.getProcessedResources().builtBy(main.getProcessResourcesTaskName()); - t.getMainClass().set(project.getProviders().gradleProperty("cn1.backend.mainClass")); - t.getTarget().set(project.getProviders().gradleProperty("cn1.backend.target")); - t.getJdk().set(project.getProviders().gradleProperty("cn1.backend.jdk")); - t.getCflags().set(project.getProviders().gradleProperty("cn1.backend.cflags")); - t.getSqlite().set(project.getProviders().gradleProperty("cn1.backend.sqlite") - .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); - t.getCheckedCasts().set(project.getProviders().gradleProperty("cn1.backend.checkedCasts") - .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); - t.getDevTools().set(project.getProviders().gradleProperty("cn1.backend.devTools") - .map(Boolean::parseBoolean).orElse(Boolean.FALSE)); t.getBinary().set(new File(layout.buildDir(), project.getName())); }); + + // The compiled test run: the backend's tests translated with it into one + // native binary, run, and reported beside the JVM run's. Opt in, as with + // Maven's -Dcn1.backend.compiledTests: it costs what a package costs. + final Configuration testRuntime = AppSupport.resolvable(project, "cn1BackendTestToolchain", + "The backend test library's sources, for the compiled test run"); + testRuntime.setTransitive(false); + project.getDependencies().addProvider(testRuntime.getName(), + ext.getVersion().map(v -> PluginInfo.GROUP + ":codenameone-backend-test:" + v + ":parparvm-sources")); + final SourceSet test = project.getExtensions().getByType(org.gradle.api.plugins.JavaPluginExtension.class) + .getSourceSets().getByName(SourceSet.TEST_SOURCE_SET_NAME); + project.getTasks().register("backendTest", TestTask.class, t -> { + configure(t, project, layout, main, ext, runtime); + t.setDescription("Runs the backend's tests as a native binary"); + t.dependsOn(test.getClassesTaskName()); + t.getToolchain().from(testRuntime); + t.getTestSources().from(test.getJava().getSrcDirs()); + // The Kotlin test directory too, so the packager can refuse Kotlin + // tests by name instead of compiling the run without them. + t.getTestSources().from(new File(layout.projectDir(), "src/test/kotlin")); + t.getTestResources().from(project.provider(() -> test.getOutput().getResourcesDir())); + t.getTestResources().builtBy(test.getProcessResourcesTaskName()); + t.getStrict().set(project.getProviders().gradleProperty("cn1.backend.compiledTests.strict") + .map(Boolean::parseBoolean).orElse(Boolean.FALSE)); + t.getBinary().set(new File(layout.buildDir(), "cn1-backend-test/" + project.getName() + "-tests")); + }); + } + + private static void configure(PackageTask t, Project project, ProjectLayout layout, SourceSet main, + CodenameOneExtension ext, Configuration runtime) { + AppSupport.common(t, project, layout, ext, project.provider(Collections::emptyMap)); + t.dependsOn(main.getClassesTaskName()); + t.getToolchain().from(runtime); + // The compile classpath, as Maven's backend-package resolves (scope + // compile), not the runtime one: the binary is translated by ParparVM, + // which loads nothing by name (Class.forName is banned in the backend), + // so a runtimeOnly library -- a JDBC driver, say -- could not run in it. + t.getCompileClasspath().from(main.getCompileClasspath()); + t.getArtifacts().set(project.getConfigurations().getByName(main.getCompileClasspathConfigurationName()) + .getIncoming().artifactView(v -> v.setLenient(true)).getArtifacts().getResolvedArtifacts() + .map(set -> AppSupport.encode(set, "compile"))); + t.getSources().from(main.getJava().getSrcDirs()); + // What annotation processors (annotationProcessor(...)) generated: the + // packager compiles the sources again, without javac's processor path, + // so it needs their output the way runBackend has it from the classes. + t.getSources().from(project.getTasks().named(main.getCompileJavaTaskName(), + org.gradle.api.tasks.compile.JavaCompile.class) + .flatMap(c -> c.getOptions().getGeneratedSourceOutputDirectory())); + t.getSourceEncoding().set(AppSupport.javaEncoding(project, main)); + t.getProcessedResources().from(project.provider(() -> main.getOutput().getResourcesDir())); + t.getProcessedResources().builtBy(main.getProcessResourcesTaskName()); + t.getMainClass().set(project.getProviders().gradleProperty("cn1.backend.mainClass")); + t.getTarget().set(project.getProviders().gradleProperty("cn1.backend.target")); + t.getJdk().set(project.getProviders().gradleProperty("cn1.backend.jdk")); + t.getCflags().set(project.getProviders().gradleProperty("cn1.backend.cflags")); + t.getSqlite().set(project.getProviders().gradleProperty("cn1.backend.sqlite") + .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); + t.getCheckedCasts().set(project.getProviders().gradleProperty("cn1.backend.checkedCasts") + .map(Boolean::parseBoolean).orElse(Boolean.TRUE)); + t.getDevTools().set(project.getProviders().gradleProperty("cn1.backend.devTools") + .map(Boolean::parseBoolean).orElse(Boolean.FALSE)); } /// See [BackendPackageSupport]. @@ -191,7 +222,7 @@ public void build() { final java.util.Set toolchain = getToolchain().getFiles(); java.util.Iterator resources = getProcessedResources().getFiles().iterator(); File processed = resources.hasNext() ? resources.next() : layout.resourcesOutputDir(); - BackendPackager packager = new GradleBackendPackager(host, layout, toolchain, processed); + BackendPackager packager = packager(host, layout, toolchain, processed); try { packager.mainClass(getMainClass().getOrNull()).output(getBinary().get().getAsFile()) .target(getTarget().getOrNull()).jdk(getJdk().getOrNull(), null).cflags(getCflags().getOrNull()) @@ -204,6 +235,24 @@ public void build() { /// The packager, with the Gradle project's resources and the toolchain /// jars the task resolved in place of Maven's repository lookup. + /// The packager this task runs. + protected BackendPackager packager(ProjectHost host, ProjectLayout layout, java.util.Set toolchain, + File processed) { + return new GradleBackendPackager(host, layout, toolchain, processed); + } + + /// Finds a toolchain artifact by name, the way both packagers resolve. + static File fromToolchain(java.util.Set toolchain, String groupId, String artifactId, String version, + String classifier) throws BuildExecutionException { + for (File f : toolchain) { + if (f.getName().startsWith(artifactId + "-") && f.getName().endsWith("-" + classifier + ".jar")) { + return f; + } + } + throw new BuildExecutionException("Could not resolve " + groupId + ":" + artifactId + ":" + + version + ":" + classifier); + } + private static final class GradleBackendPackager extends BackendPackager { private final ProjectLayout layout; private final java.util.Set toolchain; @@ -230,14 +279,84 @@ protected boolean declaresResources() { @Override protected File resolve(String groupId, String artifactId, String version, String classifier) throws BuildExecutionException { - for (File f : toolchain) { - if (f.getName().startsWith(artifactId + "-") && f.getName().endsWith("-" + classifier + ".jar")) { - return f; - } - } - throw new BuildExecutionException("Could not resolve " + groupId + ":" + artifactId + ":" - + version + ":" + classifier); + return fromToolchain(toolchain, groupId, artifactId, version, classifier); + } + } + } + + /// The test packager, answering from the Gradle task's inputs. + private static final class GradleBackendTestPackager extends com.codename1.maven.BackendTestPackager { + private final ProjectLayout layout; + private final java.util.Set toolchain; + private final File processed; + private final List roots; + private final File testResources; + + GradleBackendTestPackager(ProjectHost host, ProjectLayout layout, java.util.Set toolchain, + File processed, List roots, File testResources) { + super(host); + this.layout = layout; + this.toolchain = toolchain; + this.processed = processed; + this.roots = roots; + this.testResources = testResources; + } + + @Override + protected List testSourceRoots() { + return roots; + } + + @Override + protected File testOutputDirectory() { + return testResources; + } + + @Override + protected File processedResourcesDirectory() { + return processed; + } + + @Override + protected boolean declaresResources() { + return layout.resourcesDir().isDirectory(); + } + + @Override + protected File resolve(String groupId, String artifactId, String version, String classifier) + throws BuildExecutionException { + return PackageTask.fromToolchain(toolchain, groupId, artifactId, version, classifier); + } + } + + /// `backendTest`: the backend's tests as a native binary, through the build + /// engine's BackendTestPackager. + @DisableCachingByDefault(because = "Runs a native toolchain and the tests") + public abstract static class TestTask extends PackageTask { + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getTestSources(); + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + public abstract ConfigurableFileCollection getTestResources(); + + @Input + public abstract Property getStrict(); + + @Override + protected BackendPackager packager(ProjectHost host, final ProjectLayout layout, + final java.util.Set toolchain, final File processed) { + final List roots = new ArrayList(); + for (File dir : getTestSources().getFiles()) { + roots.add(dir.getAbsolutePath()); } + java.util.Iterator resources = getTestResources().getFiles().iterator(); + final File testResources = resources.hasNext() ? resources.next() : null; + com.codename1.maven.BackendTestPackager p = + new GradleBackendTestPackager(host, layout, toolchain, processed, roots, testResources); + p.strict(getStrict().get()); + return p; } } } diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java index fdfb1a2d5d8..b07285a54a5 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java @@ -23,6 +23,7 @@ package com.codename1.gradle; import com.codename1.gradle.tasks.ProcessAnnotationsAction; +import com.codename1.gradle.tasks.ProcessTestAnnotationsAction; import com.codename1.gradle.tasks.RunBackendTask; import com.codename1.maven.GradleProjectTemplate; import com.codename1.project.ProjectLayout; @@ -56,6 +57,10 @@ final class BackendSupport { /// The SQLite driver the dev profile's in-memory database uses, as the Maven /// backend module declares it. static final String SQLITE_JDBC = "org.xerial:sqlite-jdbc:3.46.1.0"; + /// The JUnit a backend's tests are written against; the compiled test run + /// translates them against codenameone-backend-test's subset of the same API. + static final String JUNIT_JUPITER = "org.junit.jupiter:junit-jupiter:5.9.3"; + static final String JUNIT_LAUNCHER = "org.junit.platform:junit-platform-launcher:1.9.3"; private BackendSupport() { } @@ -72,6 +77,7 @@ static void apply(final Project project, final ProjectLayout layout, final Coden // execution does. project.getTasks().named(main.getCompileJavaTaskName(), JavaCompile.class, compile -> { AppSupport.processingInputs(compile, layout, userProperties); + backendSettingsInputs(project, compile, layout); compile.doLast("processCn1Annotations", new ProcessAnnotationsAction( compile.getDestinationDirectory().getAsFile(), AppSupport.stubsDir(layout), layout.projectDir(), layout.settingsFile(), project.provider(() -> AppSupport.sourceRoots(main, layout)), @@ -87,6 +93,7 @@ static void apply(final Project project, final ProjectLayout layout, final Coden project.getPluginManager().withPlugin("org.jetbrains.kotlin.jvm", kotlin -> project.getTasks().named("compileKotlin").configure(compile -> { AppSupport.processingInputs(compile, layout, userProperties); + backendSettingsInputs(project, compile, layout); compile.doLast("processCn1Annotations", new ProcessAnnotationsAction( AppSupport.kotlinDestinationProvider(compile, layout), AppSupport.stubsDir(layout), layout.projectDir(), layout.settingsFile(), @@ -96,6 +103,8 @@ static void apply(final Project project, final ProjectLayout layout, final Coden .withSourceEncoding(AppSupport.javaEncoding(project, main))); })); + applyTests(project, layout, ext, main); + project.getTasks().register("runBackend", RunBackendTask.class, t -> { t.setGroup(AppSupport.GROUP); t.setDescription("Runs the backend on this JVM (the fast development loop)"); @@ -113,6 +122,71 @@ static void apply(final Project project, final ProjectLayout layout, final Coden UpdateSupport.register(project, layout); } + /// `@BackendTest` support, as the Maven archetype's backend module has it: the + /// test library and JUnit 5, a fresh JVM per test class (one server runs per + /// process), and the test pass after the test classes compile. + private static void applyTests(final Project project, final ProjectLayout layout, + final CodenameOneExtension ext, final SourceSet main) { + AppSupport.addFramework(project, "testImplementation", ext.getVersion(), "codenameone-backend-test"); + project.getDependencies().add("testImplementation", JUNIT_JUPITER); + project.getDependencies().add("testRuntimeOnly", JUNIT_LAUNCHER); + final SourceSet test = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets() + .getByName(SourceSet.TEST_SOURCE_SET_NAME); + final Provider> roots = project.provider(() -> { + List all = new ArrayList(AppSupport.sourceRoots(main, layout)); + for (File dir : test.getJava().getSrcDirs()) { + all.add(dir.getAbsolutePath()); + } + all.add(new File(layout.projectDir(), "src/test/kotlin").getAbsolutePath()); + return all; + }); + final File stubs = new File(layout.buildDir(), "generated/sources/cn1-test-stubs"); + // A mixed Java and Kotlin test set is one hierarchy -- a Kotlin test can + // extend a Java @BackendTest base -- so the pass reads both outputs at once. + // Kotlin compiles first and Java after it, against the Kotlin classes: the + // pass runs after the Java compile, over both, and after the Kotlin one only + // when there is no Java test source (compileTestJava is then NO-SOURCE and + // runs no action). + final org.gradle.api.file.ConfigurableFileCollection kotlinTestClasses = project.files(); + project.getTasks().named(test.getCompileJavaTaskName(), JavaCompile.class, compile -> { + // The generated contexts embed application.properties and the profile's + // file, so a change to either alone must recompile and regenerate them. + backendSettingsInputs(project, compile, layout); + compile.doLast("processCn1TestAnnotations", new ProcessTestAnnotationsAction( + main.getOutput().getClassesDirs(), compile.getDestinationDirectory().getAsFile(), + stubs, layout.projectDir(), roots, AppSupport.javaEncoding(project, main), + compile.getClasspath(), kotlinTestClasses, null)); + }); + project.getPluginManager().withPlugin("org.jetbrains.kotlin.jvm", kotlin -> + project.getTasks().named("compileTestKotlin").configure(compile -> { + backendSettingsInputs(project, compile, layout); + Provider kotlinOut = AppSupport.kotlinDestinationProvider(compile, layout); + kotlinTestClasses.from(kotlinOut); + compile.doLast("processCn1TestAnnotations", new ProcessTestAnnotationsAction( + main.getOutput().getClassesDirs(), kotlinOut, stubs, + layout.projectDir(), roots, AppSupport.javaEncoding(project, main), + test.getCompileClasspath(), null, test.getJava())); + })); + project.getTasks().withType(org.gradle.api.tasks.testing.Test.class).configureEach(t -> { + t.useJUnitPlatform(); + t.setForkEvery(1L); + }); + } + + /// The backend's application.properties and profile files as inputs of + /// `compile`: the build compiles them into the server, and into each test's + /// context, so a settings-only edit must not leave the task up to date. + private static void backendSettingsInputs(Project project, org.gradle.api.Task compile, ProjectLayout layout) { + // Both places the processor reads them from: the project directory, and the + // conventional src/main/resources, where most projects keep them. + compile.getInputs().files(project.fileTree(layout.projectDir(), tree -> { + tree.include("application.properties", "application-*.properties", + "src/main/resources/application.properties", + "src/main/resources/application-*.properties"); + })).withPropertyName("cn1BackendSettings") + .withPathSensitivity(org.gradle.api.tasks.PathSensitivity.RELATIVE); + } + static List split(String s) { String t = s.trim(); return t.isEmpty() ? Collections.emptyList() : new ArrayList(Arrays.asList(t.split("\\s+"))); diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessTestAnnotationsAction.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessTestAnnotationsAction.java new file mode 100644 index 00000000000..9637001020e --- /dev/null +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessTestAnnotationsAction.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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gradle.tasks; + +import com.codename1.build.BuildExecutionException; +import com.codename1.gradle.GradleLog; +import com.codename1.maven.processors.BackendTests; +import org.gradle.api.Action; +import org.gradle.api.GradleException; +import org.gradle.api.Task; +import org.gradle.api.file.FileCollection; +import org.gradle.api.provider.Provider; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/// Runs after a backend's test classes compile: makes its `@BackendTest` classes +/// runnable, as the Maven plugin's `process-test-annotations` goal does. +/// +/// For each `@BackendTest` class the build engine generates the context and the +/// test wiring the `com.codename1.backend.test` runtime starts, and rewrites the +/// test classes so their injected fields are set without reflection. A project with +/// no `@BackendTest` is left untouched. +public final class ProcessTestAnnotationsAction implements Action { + private final FileCollection mainClassDirs; + private final Provider testClasses; + private final File stubDir; + private final File projectDir; + private final Provider> sourceRoots; + private final Provider encoding; + private final FileCollection testClasspath; + private final FileCollection otherTestClasses; + private final FileCollection deferTo; + + public ProcessTestAnnotationsAction(FileCollection mainClassDirs, Provider testClasses, File stubDir, + File projectDir, Provider> sourceRoots, + Provider encoding, FileCollection testClasspath) { + this(mainClassDirs, testClasses, stubDir, projectDir, sourceRoots, encoding, testClasspath, null, null); + } + + /// `otherTestClasses`: the other compiler's test output, read as part of the + /// same tests -- a Kotlin test can extend a Java `@BackendTest` base, or use a + /// Java `@TestConfiguration`. `deferTo`: sources whose compile runs after this + /// one and repeats the pass over both outputs, so this one does nothing while + /// they exist (the Java tests, for the pass after the Kotlin compile). + public ProcessTestAnnotationsAction(FileCollection mainClassDirs, Provider testClasses, File stubDir, + File projectDir, Provider> sourceRoots, + Provider encoding, FileCollection testClasspath, + FileCollection otherTestClasses, FileCollection deferTo) { + this.mainClassDirs = mainClassDirs; + this.testClasses = testClasses; + this.stubDir = stubDir; + this.projectDir = projectDir; + this.sourceRoots = sourceRoots; + this.encoding = encoding; + this.testClasspath = testClasspath; + this.otherTestClasses = otherTestClasses; + this.deferTo = deferTo; + } + + @Override + public void execute(Task task) { + File tests = testClasses.get(); + if (!tests.isDirectory() || (deferTo != null && !deferTo.isEmpty())) { + return; + } + List others = new ArrayList(); + if (otherTestClasses != null) { + others.addAll(otherTestClasses.getFiles()); + } + List classpath = new ArrayList(); + for (File f : testClasspath) { + classpath.add(f.getAbsolutePath()); + } + try { + int generated = BackendTests.process(new ArrayList(mainClassDirs.getFiles()), tests, others, + stubDir, projectDir, + new ArrayList(sourceRoots.get()), encoding.get(), classpath, false, null, + new GradleLog(task.getLogger())); + if (generated > 0) { + task.getLogger().lifecycle("cn1: prepared " + generated + " backend test class(es)"); + } + } catch (BuildExecutionException ex) { + throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java index f0b30c09371..1a065e136e9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java @@ -235,6 +235,12 @@ protected File resolve(String groupId, String artifactId, String version, String * about it. */ private String sourceEncoding() { + return sourceEncodingOf(project); + } + + /// [#sourceEncoding] for any module; the backend-test goal compiles the same + /// sources and has to read them the same way. + static String sourceEncodingOf(MavenProject project) { Plugin compiler = project.getPlugin("org.apache.maven.plugins:maven-compiler-plugin"); if (compiler != null && compiler.getConfiguration() instanceof Xpp3Dom) { Xpp3Dom encoding = ((Xpp3Dom) compiler.getConfiguration()).getChild("encoding"); @@ -258,6 +264,14 @@ private String sourceEncoding() { private File resolve(String groupId, String artifactId, String version, String classifier) throws MojoExecutionException { + return resolve(repositorySystem, localRepository, project, groupId, artifactId, version, + classifier); + } + + /// Resolves one artifact of the backend runtime for `project`. + static File resolve(RepositorySystem repositorySystem, ArtifactRepository localRepository, + MavenProject project, String groupId, String artifactId, String version, + String classifier) throws MojoExecutionException { Artifact artifact = repositorySystem.createArtifactWithClassifier( groupId, artifactId, version, "jar", classifier); ArtifactResolutionRequest request = new ArtifactResolutionRequest(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendTestMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendTestMojo.java new file mode 100644 index 00000000000..21893615150 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendTestMojo.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.build.BuildExecutionException; +import com.codename1.build.BuildFailureException; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Component; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.apache.maven.repository.RepositorySystem; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Runs a backend module's tests as a native binary: `mvn test + * -Dcn1.backend.compiledTests=true`. + * + * The same test classes Surefire runs on the JVM, translated with the application + * and linked the way {@link BackendPackageMojo} links a server. The work is + * {@link BackendTestPackager}, in the build engine; this goal supplies the Maven + * answers, as BackendPackageMojo does for BackendPackager. + * + * Off unless asked for, because it needs the native toolchain + * {@code cn1:backend-package} needs, and takes as long as a package does. On + * Windows, which has no native backend, it says so and does nothing; the JVM run is + * the coverage there. + */ +// No @Execute: bound into the test phase, it must not fork the lifecycle again. +@Mojo(name = "backend-test", defaultPhase = LifecyclePhase.TEST, + requiresDependencyResolution = ResolutionScope.TEST) +public class BackendTestMojo extends AbstractMojo { + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Parameter(defaultValue = "${localRepository}", readonly = true, required = true) + private ArtifactRepository localRepository; + + @Component + private RepositorySystem repositorySystem; + + @Parameter(property = "cn1.backend.compiledTests", defaultValue = "false") + private boolean enabled; + + /** Fail on a test class left out of the run, or a test skipped in it. */ + @Parameter(property = "cn1.backend.compiledTests.strict", defaultValue = "false") + private boolean strict; + + @Parameter(property = "cn1.backend.compiledTests.timeoutSeconds", defaultValue = "900") + private int timeoutSeconds; + + @Parameter(property = "skipTests", defaultValue = "false") + private boolean skipTests; + + @Parameter(property = "maven.test.skip", defaultValue = "false") + private boolean skip; + + /** As for cn1:backend-package. */ + @Parameter(property = "cn1.backend.jdk") + private String jdkHome; + + /** As for cn1:backend-package. */ + @Parameter(property = "cn1.backend.jdk8", defaultValue = "${env.JDK_8_HOME}") + private String jdk8Home; + + /** As for cn1:backend-package. */ + @Parameter(property = "cn1.backend.cflags") + private String cflags; + + /** Surefire's -Dtest, which selects the compiled run's classes as it selects the JVM run's. */ + @Parameter(property = "test") + private String testSelection; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (!enabled || skip || skipTests) { + return; + } + String os = System.getProperty("os.name", ""); + if (os.regionMatches(true, 0, "windows", 0, 7)) { + getLog().warn("cn1: compiled backend tests need the native backend, which does " + + "not build on Windows; the JVM run is this platform's coverage"); + return; + } + BackendTestPackager packager = packager(); + if (!packager.hasTests()) { + getLog().info("cn1: no test sources, so no compiled backend tests"); + return; + } + try { + packager.execute(); + } catch (BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } catch (BuildExecutionException ex) { + throw new MojoExecutionException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); + } + } + + private BackendTestPackager packager() { + final SurefireSelection selection = SurefireSelection.of(project, testSelection); + BackendTestPackager p = new BackendTestPackager( + new MavenModuleHost(project, MavenLog.of(getLog()))) { + @Override + protected String binaryName() { + return project.getArtifactId(); + } + + @Override + protected File processedResourcesDirectory() { + return new File(project.getBuild().getOutputDirectory()); + } + + @Override + protected boolean declaresResources() { + return !project.getBuild().getResources().isEmpty(); + } + + @Override + protected String sourceEncoding() { + return BackendPackageMojo.sourceEncodingOf(project); + } + + @Override + protected File resolve(String groupId, String artifactId, String version, String classifier) + throws BuildExecutionException { + try { + return BackendPackageMojo.resolve(repositorySystem, localRepository, project, + groupId, artifactId, version, classifier); + } catch (MojoExecutionException ex) { + throw new BuildExecutionException(ex.getMessage(), ex); + } + } + + @Override + protected List testSourceRoots() { + List roots = new ArrayList(); + for (Object root : project.getTestCompileSourceRoots()) { + roots.add(String.valueOf(root)); + } + return roots; + } + + @Override + protected File testOutputDirectory() { + return new File(project.getBuild().getTestOutputDirectory()); + } + + @Override + protected boolean selectsTestClass(String binaryName) { + return selection.test(binaryName); + } + + @Override + protected boolean selectsTestMethod(String binaryName, String method) { + return selection.test(binaryName + "#" + method); + } + }; + p.strict(strict).timeoutSeconds(timeoutSeconds); + p.jdk(jdkHome, jdk8Home).cflags(cflags); + return p; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessTestAnnotationsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessTestAnnotationsMojo.java new file mode 100644 index 00000000000..46855fda74f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessTestAnnotationsMojo.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import com.codename1.maven.processors.BackendTests; + +import org.apache.maven.artifact.DependencyResolutionRequiredException; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/// PROCESS_TEST_CLASSES: makes a backend module's `@BackendTest` classes runnable. +/// +/// The test counterpart of `process-annotations`. For each `@BackendTest` class it +/// generates the context and the test wiring the `com.codename1.backend.test` +/// runtime starts, and rewrites the test classes so their injected fields are set +/// without reflection -- see `BackendTestGenerator`. A module with no +/// `@BackendTest` is left untouched. +@Mojo(name = "process-test-annotations", + defaultPhase = LifecyclePhase.PROCESS_TEST_CLASSES, + requiresDependencyResolution = ResolutionScope.TEST, + threadSafe = true) +public class ProcessTestAnnotationsMojo extends AbstractMojo { + @Parameter(defaultValue = "${project}", readonly = true, required = true) + private MavenProject project; + + @Parameter(defaultValue = "${project.build.outputDirectory}", required = true) + private File outputDirectory; + + @Parameter(defaultValue = "${project.build.testOutputDirectory}", required = true) + private File testOutputDirectory; + + @Parameter(property = "maven.test.skip", defaultValue = "false") + private boolean skip; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (skip || !testOutputDirectory.isDirectory()) { + return; + } + List roots = new ArrayList(); + roots.addAll(project.getCompileSourceRoots()); + roots.addAll(project.getTestCompileSourceRoots()); + List classpath; + try { + classpath = project.getTestClasspathElements(); + } catch (DependencyResolutionRequiredException err) { + throw new MojoExecutionException("The test classpath is unresolved: " + err.getMessage(), + err); + } + String encoding = project.getProperties().getProperty("project.build.sourceEncoding", + "UTF-8"); + int generated; + try { + generated = BackendTests.process(outputDirectory, testOutputDirectory, + new File(project.getBuild().getDirectory(), "generated-test-sources/cn1"), + project.getBasedir(), roots, encoding, classpath, false, MavenLog.of(getLog())); + } catch (com.codename1.build.BuildFailureException e) { + throw new MojoFailureException(e.getMessage(), e.getCause() == null ? e : e.getCause()); + } catch (com.codename1.build.BuildExecutionException e) { + throw new MojoExecutionException(e.getMessage(), e.getCause() == null ? e : e.getCause()); + } + if (generated > 0) { + getLog().info("cn1: prepared " + generated + " backend test class(es)"); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SurefireSelection.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SurefireSelection.java new file mode 100644 index 00000000000..071e28f9020 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SurefireSelection.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.apache.maven.model.Plugin; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +/// The test classes Surefire runs, as the compiled backend test run must select +/// them: its `includes` and `excludes` (Surefire's defaults when the pom names +/// none), or `-Dtest` when that is given. The two runs are meant to exercise one +/// set of tests; a helper class with an `@Test` method, or an `IntegrationSpec` +/// the includes leave out, must not run in one and not the other. +/// +/// Patterns are matched as Surefire matches them, against the class's path with +/// a `.java` suffix: `**` crosses directories, `*` and `?` do not. Tag selection +/// (`groups`, `excludedGroups`) is not read; a compiled run of a tagged-out class +/// is reported like any other. +final class SurefireSelection implements java.util.function.Predicate { + static final List DEFAULT_INCLUDES = Arrays.asList( + "**/Test*.java", "**/*Test.java", "**/*Tests.java", "**/*TestCase.java"); + static final List DEFAULT_EXCLUDES = Arrays.asList("**/*$*"); + + private final List includes = new ArrayList(); + private final List excludes = new ArrayList(); + /// `-Dtest` entries that name a whole class, which select all its methods. + private final List wholeClasses = new ArrayList(); + /// `-Dtest` method selectors, `{class, method}`: `ApiTest#greets` selects only + /// that method of the class, `!ApiTest#slow` leaves just that one out. + private final List methods = new ArrayList(); + private final List excludedMethods = new ArrayList(); + + SurefireSelection(List includes, List excludes) { + for (String p : includes) { + this.includes.add(glob(p)); + } + for (String p : excludes) { + this.excludes.add(glob(p)); + } + } + + /// The selection `project`'s Surefire makes; `test` is the `-Dtest` value, or null. + static SurefireSelection of(MavenProject project, String test) { + if (test != null && test.trim().length() > 0) { + List named = new ArrayList(); + List excluded = new ArrayList(DEFAULT_EXCLUDES); + List whole = new ArrayList(); + List methods = new ArrayList(); + List excludedMethods = new ArrayList(); + for (String entry : test.split(",")) { + String e = entry.trim(); + // A "!" entry excludes, as Surefire reads it: -Dtest='*Test,!SlowTest' + // must leave SlowTest out of the compiled run as it does the JVM's. + boolean negative = e.startsWith("!"); + if (negative) { + e = e.substring(1).trim(); + } + String method = null; + boolean regex = e.startsWith(REGEX_PREFIX); + int hash = e.indexOf('#', regex ? Math.max(e.indexOf(']'), 0) : 0); + if (hash >= 0) { + method = e.substring(hash + 1).trim(); + e = e.substring(0, hash); + } + if (e.length() == 0) { + continue; + } + // A bare class name pattern names a class in any package; a regex is + // Surefire's own syntax and is kept as written. + String pattern = regex || e.indexOf('/') >= 0 || e.endsWith(".java") ? e + : "**/" + e.replace('.', '/') + ".java"; + if (method != null && method.length() > 0) { + // A method selector narrows a class rather than excluding it: + // a class excluded by method still runs its other methods. + (negative ? excludedMethods : methods).add(new String[] {pattern, method}); + if (!negative) { + named.add(pattern); + } + } else { + (negative ? excluded : named).add(pattern); + if (!negative) { + whole.add(pattern); + } + } + } + // Only exclusions: Surefire runs its usual includes minus those. + SurefireSelection out = new SurefireSelection(named.isEmpty() ? DEFAULT_INCLUDES : named, + excluded); + for (String p : whole) { + out.wholeClasses.add(glob(p)); + } + for (String[] m : methods) { + out.methods.add(new Pattern[] {glob(m[0]), methodGlob(m[1])}); + } + for (String[] m : excludedMethods) { + out.excludedMethods.add(new Pattern[] {glob(m[0]), methodGlob(m[1])}); + } + return out; + } + Plugin surefire = project.getPlugin("org.apache.maven.plugins:maven-surefire-plugin"); + Xpp3Dom config = surefire == null || !(surefire.getConfiguration() instanceof Xpp3Dom) ? null + : (Xpp3Dom) surefire.getConfiguration(); + List includes = patterns(config, "includes", "include"); + List excludes = patterns(config, "excludes", "exclude"); + return new SurefireSelection(includes == null ? DEFAULT_INCLUDES : includes, + excludes == null ? DEFAULT_EXCLUDES : excludes); + } + + private static List patterns(Xpp3Dom config, String list, String item) { + Xpp3Dom parent = config == null ? null : config.getChild(list); + if (parent == null) { + return null; + } + List out = new ArrayList(); + for (Xpp3Dom child : parent.getChildren(item)) { + if (child.getValue() != null && child.getValue().trim().length() > 0) { + out.add(child.getValue().trim()); + } + } + return out; + } + + /// Whether the run selects the class `binaryName`, or -- given as + /// `binaryName#method` -- that test method of a selected class. + @Override + public boolean test(String binaryName) { + int hash = binaryName.indexOf('#'); + if (hash >= 0) { + String cls = binaryName.substring(0, hash); + return test(cls) && selectsMethod(cls, binaryName.substring(hash + 1)); + } + boolean included = false; + for (Pattern p : includes) { + if (matches(p, binaryName)) { + included = true; + break; + } + } + if (!included) { + return false; + } + for (Pattern p : excludes) { + if (matches(p, binaryName)) { + return false; + } + } + return true; + } + + /// Whether `-Dtest` lets test `method` of class `binaryName` run: every method + /// when the class was named whole or no method selector names it, else only the + /// methods its selectors name -- and never one a `!` selector names. + private boolean selectsMethod(String binaryName, String method) { + for (Pattern[] m : excludedMethods) { + if (matches(m[0], binaryName) && m[1].matcher(method).matches()) { + return false; + } + } + for (Pattern p : wholeClasses) { + if (matches(p, binaryName)) { + return true; + } + } + boolean narrowed = false; + for (Pattern[] m : methods) { + if (matches(m[0], binaryName)) { + if (m[1].matcher(method).matches()) { + return true; + } + narrowed = true; + } + } + return !narrowed; + } + + /// A Surefire method selector as a regular expression: `+` separates + /// alternatives (`#one+two`), `*` matches anything and `?` one character. + static Pattern methodGlob(String pattern) { + StringBuilder re = new StringBuilder(); + for (String alternative : pattern.split("\\+")) { + if (re.length() > 0) { + re.append('|'); + } + re.append("(?:"); + for (int i = 0 ; i < alternative.length() ; i++) { + char c = alternative.charAt(i); + if (c == '*') { + re.append(".*"); + } else if (c == '?') { + re.append('.'); + } else if ("\\.[]{}()+-^$|".indexOf(c) >= 0) { + re.append('\\').append(c); + } else { + re.append(c); + } + } + re.append(')'); + } + return Pattern.compile(re.toString()); + } + + /// An Ant-style path pattern as a regular expression. A `.class` pattern is + /// read as its `.java` counterpart, as Surefire treats the two alike. + /// Whether `pattern` selects the class `binaryName`. A glob is matched against + /// the class's `.java` path, as Surefire matches it; a `%regex[...]` pattern + /// against its `.class` path, as Surefire documents for regex selection. A glob + /// always ends in `.java` (a `.class` one is read as such), so trying both + /// forms cannot widen what it selects. + private static boolean matches(Pattern pattern, String binaryName) { + String path = binaryName.replace('.', '/'); + return pattern.matcher(path + ".java").matches() || pattern.matcher(path + ".class").matches(); + } + + private static final String REGEX_PREFIX = "%regex["; + + static Pattern glob(String pattern) { + if (pattern.startsWith(REGEX_PREFIX) && pattern.endsWith("]")) { + // Surefire's regex selection: the pattern between the brackets is a Java + // regular expression over the class's path. Fed to the glob converter its + // operators were literal characters, and it selected nothing at all. + return Pattern.compile(pattern.substring(REGEX_PREFIX.length(), pattern.length() - 1)); + } + String p = pattern.replace('\\', '/'); + if (p.endsWith(".class")) { + p = p.substring(0, p.length() - ".class".length()) + ".java"; + } + StringBuilder re = new StringBuilder(); + for (int i = 0 ; i < p.length() ; i++) { + char c = p.charAt(i); + if (c == '*' && i + 1 < p.length() && p.charAt(i + 1) == '*') { + // "**/" matches any number of directories, none included. + if (i + 2 < p.length() && p.charAt(i + 2) == '/') { + re.append("(?:.*/)?"); + i += 2; + } else { + re.append(".*"); + i++; + } + } else if (c == '*') { + re.append("[^/]*"); + } else if (c == '?') { + re.append("[^/]"); + } else if ("\\.[]{}()+-^$|".indexOf(c) >= 0) { + re.append('\\').append(c); + } else { + re.append(c); + } + } + return Pattern.compile(re.toString()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SurefireSelectionTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SurefireSelectionTest.java new file mode 100644 index 00000000000..736588b7e9d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SurefireSelectionTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.maven; + +import org.apache.maven.model.Build; +import org.apache.maven.model.Model; +import org.apache.maven.model.Plugin; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The compiled backend test run selects the classes Surefire runs. +class SurefireSelectionTest { + @Test + void surefiresDefaultsSelectTestClassesAndSkipHelpersAndNestedClasses() { + SurefireSelection s = SurefireSelection.of(project(null, null), null); + assertTrue(s.test("com.acme.ApiTest")); + assertTrue(s.test("com.acme.TestSupportFlows")); + assertTrue(s.test("com.acme.ApiTests")); + assertTrue(s.test("com.acme.ApiTestCase")); + assertTrue(s.test("ApiTest"), "the default package"); + assertFalse(s.test("com.acme.IntegrationSpec"), "not a name Surefire discovers"); + assertFalse(s.test("com.acme.Fixtures"), "a helper with an @Test method"); + assertFalse(s.test("com.acme.ApiTest$Nested"), "nested classes are excluded"); + } + + @Test + void thePomsIncludesAndExcludesReplaceTheDefaults() { + SurefireSelection s = SurefireSelection.of(project(new String[] {"**/*Spec.java"}, + new String[] {"**/slow/**"}), null); + assertTrue(s.test("com.acme.IntegrationSpec")); + assertFalse(s.test("com.acme.ApiTest"), "the pom's includes replace the defaults"); + assertFalse(s.test("com.acme.slow.LoadSpec"), "excluded by the pom"); + assertTrue(s.test("com.acme.ApiTest$NestedSpec"), "the pom's excludes replace the defaults"); + } + + @Test + void dashDTestSelectsByClassName() { + SurefireSelection s = SurefireSelection.of(project(null, null), "ApiTest#greets, Other*"); + assertTrue(s.test("com.acme.ApiTest")); + assertTrue(s.test("com.acme.deep.OtherTests")); + assertFalse(s.test("com.acme.ServedApiTest")); + } + + @Test + void aMethodSelectorNarrowsItsClassToThoseMethods() { + SurefireSelection s = SurefireSelection.of(project(null, null), + "ApiTest#greets+count*, Other*, !OtherTests#slow"); + assertTrue(s.test("com.acme.ApiTest#greets")); + assertTrue(s.test("com.acme.ApiTest#countsVisits"), "a * in a method selector"); + assertFalse(s.test("com.acme.ApiTest#deletes"), "a method the selector does not name"); + assertTrue(s.test("com.acme.deep.OtherTests#fast"), "a class named whole runs every method"); + assertFalse(s.test("com.acme.deep.OtherTests#slow"), "a ! method selector leaves it out"); + assertFalse(s.test("com.acme.ServedApiTest#greets"), "a class the run does not select"); + assertTrue(SurefireSelection.of(project(null, null), null).test("com.acme.ApiTest#anything"), + "with no -Dtest every method of a selected class runs"); + } + + @Test + void aRegexIncludeSelectsAsSurefireDoes() { + SurefireSelection s = SurefireSelection.of( + project(new String[] {"%regex[.*(Cat|Dog).*Test.*]"}, null), null); + assertTrue(s.test("com.acme.CatFoodTest")); + assertTrue(s.test("com.acme.deep.DogWalkTest")); + assertFalse(s.test("com.acme.BirdTest"), "a class the regex does not match"); + SurefireSelection dashD = SurefireSelection.of(project(null, null), "%regex[.*Cat.*]#feeds"); + assertTrue(dashD.test("com.acme.CatTest#feeds")); + assertFalse(dashD.test("com.acme.CatTest#sleeps")); + assertFalse(dashD.test("com.acme.DogTest#feeds")); + } + + @Test + void aNegativeDashDTestEntryExcludes() { + SurefireSelection s = SurefireSelection.of(project(null, null), "*Test, !SlowTest"); + assertTrue(s.test("com.acme.ApiTest")); + assertFalse(s.test("com.acme.SlowTest"), "-Dtest excluded it"); + SurefireSelection only = SurefireSelection.of(project(null, null), "!SlowTest"); + assertTrue(only.test("com.acme.ApiTest"), "only exclusions keep the default includes"); + assertFalse(only.test("com.acme.SlowTest")); + } + + private static MavenProject project(String[] includes, String[] excludes) { + Model model = new Model(); + model.setBuild(new Build()); + MavenProject project = new MavenProject(model); + if (includes != null || excludes != null) { + Plugin surefire = new Plugin(); + surefire.setGroupId("org.apache.maven.plugins"); + surefire.setArtifactId("maven-surefire-plugin"); + Xpp3Dom config = new Xpp3Dom("configuration"); + config.addChild(list("includes", "include", includes)); + config.addChild(list("excludes", "exclude", excludes)); + surefire.setConfiguration(config); + model.getBuild().addPlugin(surefire); + } + return project; + } + + private static Xpp3Dom list(String name, String item, String[] values) { + Xpp3Dom parent = new Xpp3Dom(name); + for (String v : values == null ? new String[0] : values) { + Xpp3Dom child = new Xpp3Dom(item); + child.setValue(v); + parent.addChild(child); + } + return parent; + } +} diff --git a/maven/integration-tests/cn1app-archetype-test.sh b/maven/integration-tests/cn1app-archetype-test.sh index 7edd16f7361..dd74693ccf7 100644 --- a/maven/integration-tests/cn1app-archetype-test.sh +++ b/maven/integration-tests/cn1app-archetype-test.sh @@ -74,6 +74,26 @@ if [ ! -f "backend/target/classes/$ROUTER_DIR/BackendWiring.class" ]; then exit 1 fi +# The sample tests, on the JVM. Counted from the reports rather than trusted from +# the exit status: a surefire that found no provider runs zero tests and succeeds, +# which is exactly how the root pom's client-test provider once swallowed them. +mvn -pl backend -Dcodename1.platform=backend test +backend_tests_ran() { + local suffix="$1" total=0 count + for report in backend/target/surefire-reports/TEST-*"$suffix".xml; do + [ -f "$report" ] || continue + count="$(sed -n 's/.*]* tests="\([0-9]*\)".*/\1/p' "$report" | head -1)" + total=$((total + ${count:-0})) + done + echo "$total" +} +JVM_TESTS="$(backend_tests_ran Test)" +if [ "$JVM_TESTS" -lt 3 ]; then + echo "the backend's sample tests did not run on the JVM (counted $JVM_TESTS)" >&2 + exit 1 +fi +echo "the backend's sample tests ran on the JVM: $JVM_TESTS" + # `cn1:backend-package` -- the goal that turns the module above into a native # binary -- ON A JDK THAT IS NOT 8, with JDK_8_HOME deliberately unset. # @@ -137,7 +157,7 @@ else fi sleep 1 done - # The route that goes through an injected @Service, while the binary still runs. + # The route that goes through an injected @Component, while the binary still runs. GREETING="$(curl -s --max-time 5 "http://127.0.0.1:$BACKEND_PORT/greet/archetype" || true)" kill -9 $BACKEND_PID 2>/dev/null || true trap 'set_auto_bundle_pref false' EXIT @@ -152,4 +172,15 @@ else exit 1 fi echo "the packaged backend answered /healthz" + kill -9 $BACKEND_PID 2>/dev/null || true + + # The same tests translated into a native test binary, on the same JDK. + env -u JDK_8_HOME JAVA_HOME="$BACKEND_PACKAGE_JDK" \ + mvn -pl backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true + COMPILED_TESTS="$(backend_tests_ran -compiled)" + if [ "$COMPILED_TESTS" -lt 3 ]; then + echo "the backend's sample tests did not run compiled (counted $COMPILED_TESTS)" >&2 + exit 1 + fi + echo "the backend's sample tests ran compiled: $COMPILED_TESTS" fi diff --git a/maven/integration-tests/gradle-app-test.sh b/maven/integration-tests/gradle-app-test.sh index 547b13955ad..3dd502956f1 100755 --- a/maven/integration-tests/gradle-app-test.sh +++ b/maven/integration-tests/gradle-app-test.sh @@ -12,7 +12,8 @@ # before anything is sent. # 4. Every cloud target stages its upload jar (codename1.stageOnly), holding # the app and that platform's native sources -- and no other platform's. -# 5. addBackend adds backend/, whose runBackend serves /healthz. +# 5. addBackend adds backend/, whose runBackend serves /healthz and whose +# sample tests pass on the JVM and, with clang, compiled. # # Needs the reactor installed (mvn install) and a JDK 17+ for Gradle; see # inc/gradle.sh for MAVEN_REPO_LOCAL and GRADLE_JAVA_HOME. @@ -161,6 +162,28 @@ run_gradle "$APP" addBackend > "$WORKDIR/add-backend.log" 2>&1 || { cat "$WORKDI [ ! -e "$APP/backend/pom.xml" ] || fail "a Gradle backend has no pom" check_backend_healthz "$APP" ":backend:runBackend" "$WORKDIR/backend-run.log" +echo "== backend tests" +# The sample tests addBackend writes, on the JVM and (with clang) compiled. Counted +# from the reports: a test task that found no engine runs nothing and succeeds. +count_tests() { + local total=0 count report + for report in "$@"; do + [ -f "$report" ] || continue + count="$(sed -n 's/.*]* tests="\([0-9]*\)".*/\1/p' "$report" | head -1)" + total=$((total + ${count:-0})) + done + echo "$total" +} +run_gradle "$APP" :backend:test > "$WORKDIR/backend-test.log" 2>&1 || { cat "$WORKDIR/backend-test.log"; fail ":backend:test"; } +JVM_TESTS="$(count_tests "$APP"/backend/build/test-results/test/TEST-*.xml)" +[ "$JVM_TESTS" -ge 3 ] || { cat "$WORKDIR/backend-test.log"; fail "the backend's tests did not run (counted $JVM_TESTS)"; } +if command -v clang > /dev/null 2>&1; then + run_gradle "$APP" :backend:backendTest > "$WORKDIR/backend-compiled.log" 2>&1 \ + || { cat "$WORKDIR/backend-compiled.log"; fail ":backend:backendTest"; } + COMPILED="$(count_tests "$APP"/backend/build/surefire-reports/TEST-*-compiled.xml)" + [ "$COMPILED" -ge 3 ] || { cat "$WORKDIR/backend-compiled.log"; fail "the backend's tests did not run compiled (counted $COMPILED)"; } +fi + # A Gradle older than the plugin supports is refused up front with the fix, # rather than failing later on an API it lacks. Opt-in: it downloads one more # Gradle distribution. diff --git a/maven/integration-tests/gradle-initializr-test.sh b/maven/integration-tests/gradle-initializr-test.sh index ed1785c0ee7..402ff02fb92 100755 --- a/maven/integration-tests/gradle-initializr-test.sh +++ b/maven/integration-tests/gradle-initializr-test.sh @@ -61,7 +61,7 @@ API=$(find "$SVC/src/main/java" -name Api.java | head -1) echo "== tasks" run_gradle "$SVC" tasks --group "codename one" > "$WORKDIR/tasks.log" 2>&1 || { cat "$WORKDIR/tasks.log"; fail "tasks"; } -for t in runBackend backendPackage cn1Update; do +for t in runBackend backendPackage backendTest cn1Update; do grep -q "^$t " "$WORKDIR/tasks.log" || { cat "$WORKDIR/tasks.log"; fail "a backend offers $t"; } done for t in cn1Css buildAndroid generateNativeInterfaces run; do @@ -71,8 +71,34 @@ done echo "== runBackend" check_backend_healthz "$SVC" runBackend "$WORKDIR/run.log" +echo "== test" +# The sample tests on the JVM, counted from the reports: a test task that found +# no engine runs nothing and succeeds. +run_gradle "$SVC" test > "$WORKDIR/test.log" 2>&1 || { cat "$WORKDIR/test.log"; fail "test"; } +backend_tests_ran() { + local total=0 count report + for report in "$SVC"/build/test-results/test/TEST-*.xml; do + [ -f "$report" ] || continue + count="$(sed -n 's/.*]* tests="\([0-9]*\)".*/\1/p' "$report" | head -1)" + total=$((total + ${count:-0})) + done + echo "$total" +} +JVM_TESTS="$(backend_tests_ran)" +[ "$JVM_TESTS" -ge 3 ] || { cat "$WORKDIR/test.log"; fail "the backend's sample tests did not run (counted $JVM_TESTS)"; } +echo " $JVM_TESTS tests on the JVM" + echo "== backendPackage" if command -v clang > /dev/null 2>&1; then + run_gradle "$SVC" backendTest > "$WORKDIR/backendtest.log" 2>&1 || { cat "$WORKDIR/backendtest.log"; fail "backendTest"; } + COMPILED=0 + for report in "$SVC"/build/surefire-reports/TEST-*-compiled.xml; do + [ -f "$report" ] || continue + count="$(sed -n 's/.*]* tests="\([0-9]*\)".*/\1/p' "$report" | head -1)" + COMPILED=$((COMPILED + ${count:-0})) + done + [ "$COMPILED" -ge 3 ] || { cat "$WORKDIR/backendtest.log"; fail "the sample tests did not run compiled (counted $COMPILED)"; } + echo " $COMPILED tests compiled" run_gradle "$SVC" backendPackage > "$WORKDIR/package.log" 2>&1 || { cat "$WORKDIR/package.log"; fail "backendPackage"; } BIN="$SVC/build/launcherprobe" [ -x "$BIN" ] || { cat "$WORKDIR/package.log"; fail "backendPackage produced no executable at $BIN"; } diff --git a/maven/pom.xml b/maven/pom.xml index c74b37a72bd..ea6b2a0329e 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -93,6 +93,7 @@ sqlite-jdbc backend + backend-test javase javase-svg + + 1.8 + 1.8 + 1.8 + 1.8 + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + org.mockito + mockito-core + 4.11.0 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + cn1-process-annotations + process-classes + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + process-test-annotations + + + + + cn1-compiled-tests + test + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/hellocodenameone/backend/server.sh b/scripts/hellocodenameone/backend/server.sh new file mode 100755 index 00000000000..70b21690c10 --- /dev/null +++ b/scripts/hellocodenameone/backend/server.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# +# Builds and runs the CI test server: scripts/hellocodenameone/backend. +# +# server.sh build [--jvm|--native] build only +# server.sh run [--jvm|--native] [--prebuilt] [--port N] [--out DIR] +# build (unless --prebuilt), then run +# in the foreground +# server.sh dist DIR a runnable JVM copy, for another job +# server.sh exec-dist DIR [--port N] [--out DIR] run a copy made by `dist` +# +# One server for every device leg: it receives the suite's screenshots over a +# websocket and serves the REST surface the app's networking tests drive. It +# replaced vm/backend/demo/cn1ss and the hand-written scripts/common/java server. +# +# --jvm (the default) runs it on this JDK; --native translates it with ParparVM and +# runs the binary, which is how it ships -- it needs clang and the OpenSSL, libcurl +# and nghttp2 headers, so only a leg that installs them asks for it. A platform +# with no native backend (Windows) always runs the JVM build. +# +# The runtime it compiles against -- codenameone-backend, codenameone-backend-test +# and the plugin's processors -- is installed from this checkout first, once per +# source digest, because a CI cache can restore an 8.0-SNAPSHOT older than the +# branch under test. CN1SS_BACKEND_SKIP_INSTALL=1 skips that for a developer who +# installed them already. Maven honours MAVEN_OPTS, so a per-checkout +# -Dmaven.repo.local reaches every invocation here. +# +# Readiness: the server prints "listening on http port N" when it accepts. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +APP="$ROOT/scripts/hellocodenameone" + +log() { echo "[cn1ss-backend] $*" >&2; } + +mvn_cmd() { + if [ -n "${MAVEN_HOME:-}" ] && [ -x "$MAVEN_HOME/bin/mvn" ]; then + echo "$MAVEN_HOME/bin/mvn" + elif command -v mvn >/dev/null 2>&1; then + command -v mvn + else + log "no Maven: set MAVEN_HOME or put mvn on the PATH" + return 1 + fi +} + +# The JDK to build and run with: 17 when the leg has one (the app's toolchain), +# otherwise whatever JAVA_HOME names. +java_home() { + if [ -n "${JAVA17_HOME:-}" ] && [ -x "$JAVA17_HOME/bin/java" ]; then + echo "$JAVA17_HOME" + elif [ -n "${JAVA_HOME:-}" ]; then + echo "$JAVA_HOME" + else + dirname "$(dirname "$(command -v java)")" + fi +} + +# The JDK to INSTALL the runtime with: 8 when the leg names one. The framework +# build requires it -- core compiles for Java 5, which a newer javac refuses -- and +# the -am fallback below builds core. +install_java_home() { + if [ -n "${JDK_8_HOME:-}" ] && [ -x "$JDK_8_HOME/bin/java" ]; then + echo "$JDK_8_HOME" + elif [ -n "${JAVA_HOME:-}" ]; then + echo "$JAVA_HOME" + else + java_home + fi +} + +is_windows() { + case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + esac + return 1 +} + +# Installs the backend runtime, its test support and the plugin, unless this +# exact source tree was installed already. `native` adds the ParparVM toolchain -- +# the bundle cn1:backend-package translates with, compiled from vm/ByteCodeTranslator +# and vm/JavaAPI -- so a translator change is what the native server is built with, +# not whatever older bundle a restored Maven cache holds. +install_runtime() { + if [ "${CN1SS_BACKEND_SKIP_INSTALL:-0}" = "1" ]; then + return 0 + fi + local mode="${1:-jvm}" modules="backend,backend-test,project-model,build-engine,codenameone-maven-plugin" + local -a toolchain=() + if [ "$mode" = "native" ]; then + modules="$modules,parparvm" + toolchain=(vm/ByteCodeTranslator/src vm/JavaAPI/src maven/parparvm/pom.xml maven/parparvm/src) + fi + local digest stamp + # Every module the server's build reads: the runtime and its test support, and + # the plugin with the build engine and project model it delegates the annotation + # and test passes to. A module left out of this digest is one whose change the + # stamp hides, so the server builds with the stale copy from the local repository. + digest="$(cd "$ROOT" && { find vm/backend/src vm/backend/impl vm/backend/native vm/backend/test \ + maven/backend maven/backend-test maven/codenameone-maven-plugin/src/main \ + maven/build-engine/src/main maven/build-engine/pom.xml \ + maven/project-model/src/main maven/project-model/pom.xml \ + maven/codenameone-maven-plugin/pom.xml maven/pom.xml ${toolchain[@]+"${toolchain[@]}"} \ + -type f -not -path '*/target/*' -print 2>/dev/null; vm/backend/shared-sources.sh 2>/dev/null; } \ + | LC_ALL=C sort | tr '\n' '\0' | xargs -0 cat 2>/dev/null | cksum | awk '{print $1}')" + stamp="$ROOT/maven/target/cn1ss-backend-installed-$mode-$digest" + if [ -f "$stamp" ]; then + return 0 + fi + log "installing the backend runtime, its test support, the build engine and the plugin from this checkout" + # The three modules alone first: a leg that built the app has installed the + # plugin's other dependencies (core, the ports, parparvm) from this checkout + # already, and rebuilding every port with -am would cost minutes for nothing. + # Where they are missing, the same install with -am builds them. + local -a install=(-B -q -f "$ROOT/maven/pom.xml" -Plocal-dev-javase install + -DskipTests -Dmaven.javadoc.skip=true -Dmaven.source.skip=true + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true) + if ! JAVA_HOME="$(install_java_home)" "$(mvn_cmd)" "${install[@]}" -pl "$modules" >&2; then + log "the plugin's dependencies are not installed; building them too (-am)" + JAVA_HOME="$(install_java_home)" "$(mvn_cmd)" "${install[@]}" -pl "$modules" -am >&2 + fi + mkdir -p "$(dirname "$stamp")" + touch "$stamp" +} + +# Compiles the server and copies its runtime dependencies beside it. +build_jvm() { + install_runtime + log "building the server for the JVM" + JAVA_HOME="$(java_home)" "$(mvn_cmd)" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend \ + -pl backend -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + install_runtime native + log "translating the server to a native binary" + JAVA_HOME="$(java_home)" "$(mvn_cmd)" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend \ + -pl backend -Dmaven.test.skip=true process-classes cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/hellocodenameone-backend" >&2 +} + +# Runs a JVM build: classes, a lib directory and the generated main. +exec_jvm() { + local classes="$1" lib="$2" port="$3" out="$4" sep=":" main cp + main="$(tr -d '\r\n' < "$classes/META-INF/cn1-backend-main")" + if is_windows; then + sep=";" + classes="$(cygpath -w "$classes")" + lib="$(cygpath -w "$lib")" + fi + cp="$classes${sep}$lib/*" + log "running $main on $(java_home)" + cd "$APP/backend" + CN1SS_OUT="$out" CN1_SERVER_PORT="$port" exec "$(java_home)/bin/java" -cp "$cp" "$main" +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${CN1SS_WS_BIND_PORT:-8765}" +out="${CN1SS_OUT:-$APP/backend/target/cn1ss-out}" +dist="" +prebuilt=0 +case "$command" in + dist|exec-dist) + dist="${1:?usage: server.sh $command DIR}" + shift + ;; +esac +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --port) port="$2"; shift ;; + --out) out="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done +if is_windows; then + mode="jvm" +fi +mkdir -p "$out" + +case "$command" in + build) + if [ "$mode" = "native" ]; then + build_native + else + build_jvm + fi + ;; + run) + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + cd "$APP/backend" + CN1SS_OUT="$out" CN1_SERVER_PORT="$port" exec "$APP/backend/target/hellocodenameone-backend" + fi + [ "$prebuilt" = 1 ] || build_jvm + exec_jvm "$APP/backend/target/classes" "$APP/backend/target/server-lib" "$port" "$out" + ;; + dist) + build_jvm + rm -rf "$dist" + mkdir -p "$dist" + cp -R "$APP/backend/target/classes" "$dist/classes" + cp -R "$APP/backend/target/server-lib" "$dist/lib" + log "a runnable copy of the server is in $dist" + ;; + exec-dist) + exec_jvm "$dist/classes" "$dist/lib" "$port" "$out" + ;; + *) + log "unknown command $command (build, run, dist, exec-dist)" + exit 2 + ;; +esac diff --git a/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpoint.java b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpoint.java new file mode 100644 index 00000000000..19ec168f720 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpoint.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocket; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.WebSocketMapping; + +import java.io.IOException; +import java.util.Map; + +/// The host-side screenshot receiver for the on-device test suites. +/// +/// Every UI-test leg in this repository -- iOS, watchOS, tvOS, macOS, Catalyst, +/// Android, Java SE, the browser, Linux and Windows -- runs its suite on a device +/// and streams each captured PNG here over a websocket. Several independent client +/// stacks reach it: NSURLSessionWebSocketTask on the Apple platforms, the +/// hand-rolled Android and Java SE clients, the browser's own WebSocket and the +/// native desktop ports. That makes this the most thoroughly exercised websocket +/// endpoint in the tree: masked, fragmented, multi-hundred-kilobyte binary +/// messages under ACK-paced flow control, on every CI run. +/// +/// ## The protocol, which is a contract with the device side +/// +/// Per screenshot the device sends a text frame +/// +/// META {"test":"","png_bytes":,"png_fnv1a64":""} +/// +/// followed by the PNG as a binary message, and then blocks until this endpoint +/// answers `ACK status=`. The ACK is +/// sent AFTER the bytes reach disk, and that ordering is the device's flow +/// control -- sending it earlier lets the next test overwrite a file this one has +/// not finished writing. `png_fnv1a64` is optional: the browser client omits it. +/// +/// `/` as well as `/cn1ss`, because the clients dial `ws://host:8765` and the +/// path the server sees depends on which client substitutes what. +@WebSocketMapping({"/cn1ss", "/"}) +public class Cn1ssEndpoint implements WebSocket { + private final ScreenshotStore store; + + public Cn1ssEndpoint(ScreenshotStore store) { + this.store = store; + } + + @Override + public void onOpen(WebSocketSession session) { + } + + @Override + public void onText(WebSocketSession session, String message) throws IOException { + if (!message.startsWith("META ")) { + return; + } + Map meta; + try { + meta = Json.parseObject(message.substring(5)); + } catch (IOException err) { + System.err.println("[cn1ss] unparseable META: " + message); + return; + } + // Per connection, not per endpoint: the endpoint is one object for every + // client, and a device that reconnects must not inherit a stale META. + session.setAttachment(new Pending(string(meta.get("test")), longOf(meta.get("png_bytes")), + string(meta.get("png_fnv1a64")))); + } + + @Override + public void onBinary(WebSocketSession session, byte[] payload, int offset, int length) + throws IOException { + Object attached = session.getAttachment(); + if (!(attached instanceof Pending)) { + System.err.println("[cn1ss] binary frame with no META; dropping " + length + " bytes"); + return; + } + Pending pending = (Pending) attached; + session.setAttachment(null); + String reply = store.write(pending.test, pending.bytes, pending.hash, payload, offset, + length); + // AFTER the bytes are on disk. The device blocks on this and moves to the + // next test when it arrives, so an earlier ACK races the file write. + session.sendText(reply); + } + + @Override + public void onError(WebSocketSession session, Exception error) { + System.err.println("[cn1ss] session error: " + error); + } + + /// The META a binary message is waiting to be matched with. + static final class Pending { + final String test; + final long bytes; + final String hash; + + Pending(String test, long bytes, String hash) { + this.test = test; + this.bytes = bytes; + this.hash = hash; + } + } + + private static String string(Object value) { + return value == null ? null : String.valueOf(value); + } + + private static long longOf(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value == null) { + return -1; + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (NumberFormatException err) { + return -1; + } + } +} diff --git a/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Pet.java b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Pet.java new file mode 100644 index 00000000000..70f7932df18 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Pet.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +/// A pet, as the app's typed client and this server both see it. The app declares +/// its own copy with the same fields; the e2e tests are what hold the two together. +public class Pet { + public long id; + public String name; + public String species; + public int age; + public boolean vaccinated; + + public Pet() { + } + + public Pet(String name, String species, int age) { + this.name = name; + this.species = species; + this.age = age; + } +} diff --git a/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetApi.java b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetApi.java new file mode 100644 index 00000000000..1dfbe77b2a8 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetApi.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.DeleteMapping; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.PutMapping; +import com.codename1.backend.annotations.RequestBody; +import com.codename1.backend.annotations.RequestMapping; +import com.codename1.backend.annotations.ResponseStatus; +import com.codename1.backend.annotations.RestController; + +import java.util.List; + +/// The typed API: what the app's generated @RestClient calls, with DTO bodies the +/// build writes codecs for on both sides. +@RestController +@RequestMapping("/api/pets") +public class PetApi { + private final PetStore store; + + public PetApi(PetStore store) { + this.store = store; + } + + @GetMapping + public List list() { + return store.all(); + } + + @GetMapping("/{id}") + public Pet get(@PathVariable("id") long id) { + return store.get(id); + } + + @PostMapping + @ResponseStatus(201) + public Pet create(@RequestBody Pet pet) { + return store.add(pet); + } + + @PutMapping("/{id}") + public Pet replace(@PathVariable("id") long id, @RequestBody Pet pet) { + return store.replace(id, pet); + } + + @DeleteMapping("/{id}") + public HttpServer.Response delete(@PathVariable("id") long id) { + return store.remove(id) ? HttpServer.Response.empty(204, null, null) + : HttpServer.Response.text(404, "no pet " + id); + } +} diff --git a/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetStore.java b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetStore.java new file mode 100644 index 00000000000..90a1ffaa657 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetStore.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.annotations.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/// The pets, in memory: the server is a test fixture and starts empty each run. +@Component +public class PetStore { + private final Map pets = new TreeMap(); + private long nextId = 1; + + public synchronized Pet add(Pet pet) { + Pet stored = copy(pet); + stored.id = nextId++; + pets.put(Long.valueOf(stored.id), stored); + return copy(stored); + } + + public synchronized Pet get(long id) { + Pet pet = (Pet) pets.get(Long.valueOf(id)); + return pet == null ? null : copy(pet); + } + + public synchronized List all() { + List out = new ArrayList(); + for (Object pet : pets.values()) { + out.add(copy((Pet) pet)); + } + return out; + } + + public synchronized Pet replace(long id, Pet pet) { + if (!pets.containsKey(Long.valueOf(id))) { + return null; + } + Pet stored = copy(pet); + stored.id = id; + pets.put(Long.valueOf(id), stored); + return copy(stored); + } + + public synchronized boolean remove(long id) { + return pets.remove(Long.valueOf(id)) != null; + } + + private static Pet copy(Pet pet) { + Pet out = new Pet(pet.name, pet.species, pet.age); + out.id = pet.id; + out.vaccinated = pet.vaccinated; + return out; + } +} diff --git a/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ProbeApi.java b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ProbeApi.java new file mode 100644 index 00000000000..d7d9ae676ea --- /dev/null +++ b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ProbeApi.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.Base64; +import com.codename1.backend.HttpServer; +import com.codename1.backend.HttpSession; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.DeleteMapping; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PatchMapping; +import com.codename1.backend.annotations.PathVariable; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.PutMapping; +import com.codename1.backend.annotations.RequestHeader; +import com.codename1.backend.annotations.RequestMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RequestPart; +import com.codename1.backend.annotations.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// What the app's networking tests probe that a typed API does not: every verb, +/// status codes, redirects, cookies and sessions, authentication, large and gzip +/// bodies, downloads, uploads and timeouts. Each answer is deterministic, so a +/// client test can assert on it exactly. +@RestController +@RequestMapping("/api") +public class ProbeApi { + /// The basic credentials /api/auth/basic accepts: user and pass. + static final String BASIC = "Basic " + Base64.encode(ascii("user:pass")); + /// The bearer token /api/auth/bearer accepts. + static final String TOKEN = "Bearer token-123"; + + /// The server is up, and what it is. + @GetMapping("/health") + public Map health() { + Map out = new LinkedHashMap(); + out.put("status", "up"); + out.put("server", "hellocodenameone-backend"); + return out; + } + + @GetMapping("/hello/{name}") + public String hello(@PathVariable("name") String name) { + return "Hello, " + name; + } + + @GetMapping("/echo") + public HttpServer.Response echoGet(HttpServer.Request request) { + return echo(request); + } + + @PostMapping("/echo") + public HttpServer.Response echoPost(HttpServer.Request request) { + return echo(request); + } + + @PutMapping("/echo") + public HttpServer.Response echoPut(HttpServer.Request request) { + return echo(request); + } + + @PatchMapping("/echo") + public HttpServer.Response echoPatch(HttpServer.Request request) { + return echo(request); + } + + @DeleteMapping("/echo") + public HttpServer.Response echoDelete(HttpServer.Request request) { + return echo(request); + } + + /// The request as the server saw it: method, path, query parameters, the + /// headers a test sets, and the body. + private static HttpServer.Response echo(HttpServer.Request request) { + Map out = new LinkedHashMap(); + out.put("method", request.getMethod()); + out.put("target", request.getTarget()); + Map params = new LinkedHashMap(); + String[] names = {"a", "b", "q", "page", "name"}; + for (String n : names) { + String v = request.param(n); + if (v != null) { + params.put(n, v); + } + } + out.put("params", params); + Map headers = new LinkedHashMap(); + String[] interesting = {"X-Test", "X-Other", "Accept", "Content-Type", "User-Agent", + "Authorization", "Accept-Encoding", "Host"}; + for (String h : interesting) { + String v = request.getHeader(h); + if (v != null) { + headers.put(h, v); + } + } + out.put("headers", headers); + byte[] body = request.getBodyBytes(); + out.put("bodyLength", Integer.valueOf(body == null ? 0 : body.length)); + if (body != null) { + try { + out.put("body", request.getBody()); + } catch (IllegalStateException binary) { + out.put("body", null); + } + } + return HttpServer.Response.json(200, Json.write(out)).header("X-Probe", "echo"); + } + + /// Answers with the status asked for. + @GetMapping("/status/{code}") + public HttpServer.Response status(@PathVariable("code") int code) { + if (code < 200 || code > 599) { + return HttpServer.Response.text(400, "a status is 200 to 599"); + } + if (code == 204 || code == 304) { + return HttpServer.Response.empty(code, null, null); + } + return HttpServer.Response.text(code, "status " + code); + } + + /// Redirects `n` times, then answers "landed". + @GetMapping("/redirect/{n}") + public HttpServer.Response redirect(@PathVariable("n") int n) { + if (n <= 0) { + return HttpServer.Response.text(200, "landed"); + } + return HttpServer.Response.empty(302, null, null) + .header("Location", "/api/redirect/" + (n - 1)); + } + + /// Sets a cookie. + @GetMapping("/cookie/set") + public HttpServer.Response setCookie(@RequestParam("name") String name, + @RequestParam("value") String value) { + return HttpServer.Response.text(200, "set " + name) + .header("Set-Cookie", name + "=" + value + "; Path=/"); + } + + /// The value of a cookie the client sent, or "none". + @GetMapping("/cookie/read") + public String readCookie(HttpServer.Request request, @RequestParam("name") String name) { + String value = request.getCookie(name); + return value == null ? "none" : value; + } + + /// A per-session counter, which needs the session cookie to round-trip. + @GetMapping("/session/count") + public String sessionCount(HttpServer.Request request) { + HttpSession session = request.getSession(true); + Object previous = session.getAttribute("count"); + int count = previous instanceof Integer ? ((Integer) previous).intValue() + 1 : 1; + session.setAttribute("count", Integer.valueOf(count)); + return String.valueOf(count); + } + + @GetMapping("/auth/basic") + public HttpServer.Response basic(@RequestHeader(value = "Authorization", required = false) + String authorization) { + if (BASIC.equals(authorization)) { + return HttpServer.Response.text(200, "user"); + } + return HttpServer.Response.text(401, "who are you?") + .header("WWW-Authenticate", "Basic realm=\"probe\""); + } + + @GetMapping("/auth/bearer") + public HttpServer.Response bearer(@RequestHeader(value = "Authorization", required = false) + String authorization) { + if (TOKEN.equals(authorization)) { + return HttpServer.Response.text(200, "token accepted"); + } + return HttpServer.Response.text(401, "missing or wrong token"); + } + + /// `size` characters of a repeating, compressible pattern, as JSON-free text; + /// over a kilobyte it is gzipped for a client that accepts it. + @GetMapping("/big") + public String big(@RequestParam(value = "size", defaultValue = "4096") int size) { + StringBuilder sb = new StringBuilder(size); + String pattern = "Codename One backend probe. "; + while (sb.length() < size) { + sb.append(pattern); + } + sb.setLength(Math.max(0, size)); + return sb.toString(); + } + + /// `size` bytes whose i-th byte is `i & 0xff`, as a file to download. + @GetMapping("/download/{size}") + public HttpServer.Response download(@PathVariable("size") int size) { + int n = Math.max(0, Math.min(size, 8 * 1024 * 1024)); + byte[] data = new byte[n]; + for (int iter = 0 ; iter < n ; iter++) { + data[iter] = (byte) iter; + } + return new HttpServer.Response(200, "application/octet-stream", data) + .header("Content-Disposition", "attachment; filename=\"probe.bin\""); + } + + /// A multipart upload: the file's name, size and checksum, and the fields. + @PostMapping("/upload") + public Map upload(@RequestPart("file") HttpServer.Part file, + @RequestParam(value = "note", required = false) String note) { + Map out = new LinkedHashMap(); + out.put("name", file.getName()); + out.put("filename", file.getFilename()); + out.put("contentType", file.getContentType()); + out.put("size", Integer.valueOf(file.getSize())); + out.put("sum", Long.valueOf(checksum(file.getBytes()))); + out.put("note", note); + return out; + } + + /// A raw binary body: its size and checksum. + @PostMapping("/raw") + public Map raw(HttpServer.Request request) { + byte[] body = request.getBodyBytes(); + Map out = new LinkedHashMap(); + out.put("size", Integer.valueOf(body == null ? 0 : body.length)); + out.put("sum", Long.valueOf(body == null ? 0 : checksum(body))); + return out; + } + + /// Answers after `ms` milliseconds, for timeout and cancellation tests. + @GetMapping("/slow") + public String slow(@RequestParam(value = "ms", defaultValue = "1000") int ms) + throws InterruptedException { + // A test fixture's delay, capped; nothing in the server waits on this. + Thread.sleep(Math.max(0, Math.min(ms, 30000))); + return "slow " + ms; + } + + @GetMapping("/json/list") + public List jsonList() { + List out = new ArrayList(); + for (int iter = 1 ; iter <= 5 ; iter++) { + out.add(Long.valueOf(iter)); + } + out.add("six"); + out.add(Boolean.TRUE); + return out; + } + + @GetMapping("/json/map") + public Map jsonMap() { + Map inner = new LinkedHashMap(); + inner.put("depth", Long.valueOf(2)); + inner.put("tags", jsonList()); + Map out = new LinkedHashMap(); + out.put("title", "probe"); + out.put("count", Long.valueOf(3)); + out.put("ratio", Double.valueOf(0.5)); + out.put("nested", inner); + out.put("nothing", null); + return out; + } + + /// Custom response headers, including an ETag. + @GetMapping("/headers") + public HttpServer.Response headers() { + return HttpServer.Response.text(200, "headers") + .header("X-Probe", "one") + .header("ETag", "\"v1\"") + .header("Cache-Control", "no-store"); + } + + /// A deterministic checksum both ends can compute: a 31-based polynomial. + static long checksum(byte[] data) { + long sum = 0; + for (int iter = 0 ; iter < data.length ; iter++) { + sum = (sum * 31 + (data[iter] & 0xff)) & 0xffffffffL; + } + return sum; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0 ; iter < out.length ; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } +} diff --git a/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ScreenshotStore.java b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ScreenshotStore.java new file mode 100644 index 00000000000..bedd5b1cc2e --- /dev/null +++ b/scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ScreenshotStore.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Value; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.Map; + +/// Where the screenshots go: one file per test, named from the wire after the name +/// has been made safe, with the device's length and hash checked. +/// +/// ## Why the name is sanitised twice +/// +/// The name arrives over the wire, so it cannot be trusted even though the device +/// helper already restricts it. It is folded to `[A-Za-z0-9_.-]`, refused outright +/// if it holds a separator or is `.` or `..`, and the resolved path is then checked +/// to be inside the output directory -- guarding the exact File that is written +/// rather than a separately derived value, which is what CodeQL's +/// java/path-injection asks for. +@Component +public class ScreenshotStore { + @Value("${cn1ss.out:cn1ss-out}") + private String out; + + private File dir; + /// png hash to the first test that produced it, for the duplicate warning. + private final Map hashes = new HashMap(); + private int received; + + @PostConstruct + void open() throws IOException { + dir = new File(out).getCanonicalFile(); + if (!dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("cannot create the screenshot directory " + dir); + } + } + + /// The directory screenshots are written to. + public File getDirectory() { + return dir; + } + + /// How many screenshots have been stored. + public synchronized int getReceived() { + return received; + } + + /// Writes one screenshot and returns the reply the device waits for. + public synchronized String write(String name, long declaredBytes, String declaredHash, + byte[] payload, int offset, int length) throws IOException { + String safe = sanitise(name); + if (safe == null) { + System.err.println("[cn1ss] rejected unsafe test name: " + name); + return "NACK rejected status=invalid_test_name"; + } + File target = new File(dir, safe + ".png").getCanonicalFile(); + // Guarding the exact File the stream below opens. + if (!isInside(dir, target)) { + System.err.println("[cn1ss] rejected out-of-base path: " + name + " -> " + target); + return "NACK rejected status=path_escape"; + } + String status = "ok"; + StringBuilder warn = new StringBuilder(); + if (declaredBytes >= 0 && declaredBytes != length) { + status = "length_mismatch"; + warn.append("expected=").append(declaredBytes).append(",got=").append(length); + } + String actualHash = fnv1a64Hex(payload, offset, length); + if (declaredHash != null && !declaredHash.equals(actualHash)) { + if ("ok".equals(status)) { + status = "hash_mismatch"; + } + if (warn.length() > 0) { + warn.append(';'); + } + warn.append("expected_hash=").append(declaredHash).append(",actual_hash=") + .append(actualHash); + } + Object previous = hashes.get(actualHash); + if (previous == null) { + hashes.put(actualHash, safe); + } else if (!previous.equals(safe)) { + // Two tests that produced pixel-identical images. This is what catches + // a stale frame being captured for a test that never rendered. + System.out.println("CN1SS:WARN:test=" + safe + " duplicate_image_with=" + previous + + " png_fnv1a64=" + actualHash); + } + OutputStream stream = new FileOutputStream(target); + try { + stream.write(payload, offset, length); + } finally { + stream.close(); + } + received++; + System.out.println("CN1SS:INFO:test=" + safe + " png_bytes=" + length + " png_fnv1a64=" + + actualHash + " status=" + status + (warn.length() == 0 ? "" : " warn=" + warn)); + return "ACK " + safe + " status=" + status; + } + + /// The name with every character outside `[A-Za-z0-9_.-]` replaced by `_`, or + /// null when it cannot be a file name at all. + static String sanitise(String name) { + if (name == null || name.length() == 0) { + return null; + } + if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 || name.indexOf('\0') >= 0) { + return null; + } + StringBuilder sb = new StringBuilder(name.length()); + for (int iter = 0 ; iter < name.length() ; iter++) { + char c = name.charAt(iter); + boolean alphanumeric = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9'); + sb.append(alphanumeric || c == '_' || c == '.' || c == '-' ? c : '_'); + } + String result = sb.toString(); + return ".".equals(result) || "..".equals(result) ? null : result; + } + + /// Whether `target` really sits under `base`, both already canonical. + static boolean isInside(File base, File target) { + String basePath = base.getPath(); + String targetPath = target.getPath(); + if (!targetPath.startsWith(basePath)) { + return false; + } + // "/out" must not be read as a prefix of "/output". + return targetPath.length() == basePath.length() + || targetPath.charAt(basePath.length()) == File.separatorChar; + } + + /// The same FNV-1a-64 the device computes, so the two can be compared. + static String fnv1a64Hex(byte[] bytes, int offset, int length) { + long hash = 0xcbf29ce484222325L; + long prime = 0x100000001b3L; + for (int iter = 0 ; iter < length ; iter++) { + hash ^= bytes[offset + iter] & 0xff; + hash *= prime; + } + StringBuilder sb = new StringBuilder(16); + for (int shift = 60 ; shift >= 0 ; shift -= 4) { + int nibble = (int) ((hash >>> shift) & 0xf); + sb.append((char) (nibble < 10 ? '0' + nibble : 'a' + (nibble - 10))); + } + return sb.toString(); + } +} diff --git a/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpointTest.java b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpointTest.java new file mode 100644 index 00000000000..fc87dc58d22 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpointTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Tcp; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.LocalServerPort; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Proves the screenshot endpoint works end to end before a CI leg spends forty +/// minutes finding out otherwise: a websocket to the served endpoint, a META frame +/// and a binary message SPLIT ACROSS TWO FRAMES (the common path, since the +/// hand-rolled clients fragment at 64KB), the ACK, and the file on disk. +/// +/// Through the backend's own Tcp and Crypto, so a compiled run checks the +/// translated SHA-1 binding and websocket reassembly of the binary CI starts. +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT, + properties = "cn1ss.out=target/cn1ss-test-out") +class Cn1ssEndpointTest { + private static final String TEST_NAME = "__cn1ss_selfcheck__"; + /// RFC 6455 1.3's worked example, and the accept value it prints. + private static final String RFC_KEY = "dGhlIHNhbXBsZSBub25jZQ=="; + private static final String RFC_ACCEPT = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + + @LocalServerPort + private int port; + + @Autowired + private ScreenshotStore store; + + @Test + void aFragmentedScreenshotIsStoredAndAcknowledged() throws Exception { + for (String path : new String[] {"/cn1ss", "/"}) { + Tcp connection = Tcp.connect("127.0.0.1", port, 5000); + try { + String request = "GET " + path + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + RFC_KEY + "\r\n\r\n"; + byte[] requestBytes = ascii(request); + connection.write(requestBytes, 0, requestBytes.length); + String head = readUntilBlankLine(connection); + assertTrue(head.startsWith("HTTP/1.1 101"), head); + // The accept value, not just the status: a 101 with a wrong value is + // a handshake every conforming client rejects. + assertTrue(head.indexOf("Sec-WebSocket-Accept: " + RFC_ACCEPT) >= 0, head); + + byte[] png = {(byte) 0x89, 'P', 'N', 'G'}; + String hash = ScreenshotStore.fnv1a64Hex(png, 0, png.length); + writeFrame(connection, true, 0x1, ascii("META {\"test\":\"" + TEST_NAME + + "\",\"png_bytes\":4,\"png_fnv1a64\":\"" + hash + "\"}")); + writeFrame(connection, false, 0x2, new byte[] {png[0], png[1]}); + writeFrame(connection, true, 0x0, new byte[] {png[2], png[3]}); + assertEquals("ACK " + TEST_NAME + " status=ok", readTextFrame(connection)); + File written = new File(store.getDirectory(), TEST_NAME + ".png"); + assertTrue(written.isFile() && written.length() == 4, String.valueOf(written)); + assertTrue(written.delete()); + } finally { + connection.close(); + } + } + } + + @Test + void anUnsafeNameIsRefused() { + assertEquals(null, ScreenshotStore.sanitise("../escape")); + assertEquals(null, ScreenshotStore.sanitise("..")); + assertEquals("a_b.png", ScreenshotStore.sanitise("a b.png")); + } + + /// A masked client frame, which is the only kind a server will accept. + private static void writeFrame(Tcp connection, boolean fin, int opcode, byte[] payload) + throws IOException { + byte[] mask = Crypto.randomBytes(4); + byte[] frame = new byte[2 + 4 + payload.length]; + frame[0] = (byte) ((fin ? 0x80 : 0) | opcode); + frame[1] = (byte) (0x80 | payload.length); + System.arraycopy(mask, 0, frame, 2, 4); + for (int iter = 0 ; iter < payload.length ; iter++) { + frame[6 + iter] = (byte) (payload[iter] ^ mask[iter & 3]); + } + connection.write(frame, 0, frame.length); + } + + private static String readTextFrame(Tcp connection) throws IOException { + int first = readByte(connection); + if (first < 0) { + return null; + } + int length = readByte(connection) & 0x7f; + if (length == 126) { + length = (readByte(connection) << 8) | readByte(connection); + } + byte[] payload = new byte[length]; + int got = 0; + while (got < length) { + int read = connection.read(payload, got, length - got); + if (read <= 0) { + throw new IOException("the connection ended after " + got + " of " + length); + } + got += read; + } + return new String(payload, 0, length, "UTF-8"); + } + + private static String readUntilBlankLine(Tcp connection) throws IOException { + StringBuilder out = new StringBuilder(); + while (out.length() < 8192) { + int c = readByte(connection); + if (c < 0) { + break; + } + out.append((char) c); + int n = out.length(); + if (n >= 4 && out.charAt(n - 4) == '\r' && out.charAt(n - 3) == '\n' + && out.charAt(n - 2) == '\r' && out.charAt(n - 1) == '\n') { + break; + } + } + return out.toString(); + } + + private static int readByte(Tcp connection) throws IOException { + byte[] one = new byte[1]; + int read = connection.read(one, 0, 1); + return read <= 0 ? -1 : one[0] & 0xff; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0 ; iter < out.length ; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } +} diff --git a/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/FakeStoreTest.java b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/FakeStoreTest.java new file mode 100644 index 00000000000..77f07c28c63 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/FakeStoreTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Primary; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.TestConfiguration; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A test bean replacing the application's, with no mocking library: a +/// `@Primary` bean of the same type wins every injection. Runs on the JVM and +/// compiled alike. +@BackendTest +class FakeStoreTest { + @Autowired + private MockMvc mvc; + + @Autowired + private PetStore store; + + @TestConfiguration + static class Fakes { + @Bean + @Primary + public PetStore cannedStore() { + PetStore canned = new PetStore(); + canned.add(new Pet("Canned", "fish", 1)); + return canned; + } + } + + @Test + void theControllerGetsTheTestsStore() throws Exception { + mvc.perform(get("/api/pets/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Canned")); + assertTrue(store.get(1) != null, "the test was injected another store"); + } +} diff --git a/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/MockedStoreTest.java b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/MockedStoreTest.java new file mode 100644 index 00000000000..ff79d411e2b --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/MockedStoreTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockitoBean; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/// `@MockitoBean` replacing the store with a Mockito mock. JVM only: the compiled +/// run leaves this class out, because Mockito builds classes at run time. +@BackendTest +class MockedStoreTest { + @Autowired + private MockMvc mvc; + + @MockitoBean + private PetStore store; + + @Test + void theControllerCallsTheMock() throws Exception { + Pet mocked = new Pet("Mocked", "owl", 9); + mocked.id = 7; + when(store.get(7)).thenReturn(mocked); + mvc.perform(get("/api/pets/7")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.species").value("owl")); + verify(store).get(7); + } + + @Test + void theMockIsResetBetweenTests() throws Exception { + // Unstubbed after the test above: a mock answers null, and the route 404s. + mvc.perform(get("/api/pets/7")).andExpect(status().isNotFound()); + } +} diff --git a/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/PetApiTest.java b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/PetApiTest.java new file mode 100644 index 00000000000..5210e1da416 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/PetApiTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MediaType; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.multipart; +import static com.codename1.backend.test.MockMvcRequestBuilders.options; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcRequestBuilders.delete; +import static com.codename1.backend.test.MockMvcResultMatchers.content; +import static com.codename1.backend.test.MockMvcResultMatchers.cookie; +import static com.codename1.backend.test.MockMvcResultMatchers.header; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.redirectedUrl; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The typed API through MockMvc: in process, no socket. +@BackendTest +class PetApiTest { + @Autowired + private MockMvc mvc; + + @Test + void createReadUpdateDelete() throws Exception { + String created = mvc.perform(post("/api/pets").contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Rex\",\"species\":\"dog\",\"age\":3}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Rex")) + .andExpect(jsonPath("$.age").value(3)) + .andReturn().getResponse().getContentAsString(); + long id = idOf(created); + + mvc.perform(get("/api/pets/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.species").value("dog")) + .andExpect(jsonPath("$.vaccinated").value(false)); + mvc.perform(put("/api/pets/{id}", id).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Rex\",\"species\":\"dog\",\"age\":4," + + "\"vaccinated\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.age").value(4)); + mvc.perform(get("/api/pets")) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].vaccinated").value(true)); + mvc.perform(delete("/api/pets/{id}", id)).andExpect(status().isNoContent()); + mvc.perform(get("/api/pets/{id}", id)).andExpect(status().isNotFound()); + } + + @Test + void malformedJsonIsTheClientsError() throws Exception { + mvc.perform(post("/api/pets").contentType(MediaType.APPLICATION_JSON).content("{not json")) + .andExpect(status().isBadRequest()); + } + + @Test + void aFailedExpectationSaysWhatItSaw() { + // A lambda, through assertThrows: the compiled run translates both, so this + // is also the check that the JUnit subset behaves the same in the binary. + AssertionError failed = assertThrows(AssertionError.class, + () -> mvc.perform(get("/api/pets/{id}", 999)).andExpect(status().isOk())); + assertTrue(failed.getMessage().contains("404"), failed.getMessage()); + } + + /// The "id" of a JSON object, read without a parser or a regular expression, + /// either of which a compiled test would have to bring along. + private static long idOf(String json) { + int at = json.indexOf("\"id\":") + 5; + int end = at; + while (end < json.length() && Character.isDigit(json.charAt(end))) { + end++; + } + return Long.parseLong(json.substring(at, end)); + } +} diff --git a/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ProbeApiTest.java b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ProbeApiTest.java new file mode 100644 index 00000000000..9030e92a469 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ProbeApiTest.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMultipartFile; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.multipart; +import static com.codename1.backend.test.MockMvcRequestBuilders.options; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcRequestBuilders.delete; +import static com.codename1.backend.test.MockMvcResultMatchers.content; +import static com.codename1.backend.test.MockMvcResultMatchers.cookie; +import static com.codename1.backend.test.MockMvcResultMatchers.header; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.redirectedUrl; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +/// The probe routes the app's networking tests use, checked in process first, so a +/// device failure points at the client rather than the server. +@BackendTest +class ProbeApiTest { + @Autowired + private MockMvc mvc; + + @Test + void echoesTheRequest() throws Exception { + mvc.perform(post("/api/echo").param("a", "1").header("X-Test", "yes") + .contentType("text/plain").content("body text")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Probe", "echo")) + .andExpect(jsonPath("$.method").value("POST")) + .andExpect(jsonPath("$.params.a").value("1")) + .andExpect(jsonPath("$.headers.X-Test").value("yes")) + .andExpect(jsonPath("$.body").value("body text")); + } + + @Test + void mockMvcReportsWhatTheWireCarries() throws Exception { + // A test's own lower-case Host is the Host the request carries; a default + // added beside it under another spelling used to replace it. + String echoed = mvc.perform(get("/api/echo").header("host", "api.example")) + .andReturn().getResponse().getContentAsString(); + assertTrue(echoed.indexOf("api.example") >= 0, echoed); + assertTrue(echoed.indexOf("localhost") < 0, echoed); + // Content-Type is a header like any other, though the server keeps it apart. + mvc.perform(get("/api/hello/{name}", "Ada")) + .andExpect(header().exists("Content-Type")) + .andExpect(header().string("content-type", "text/plain; charset=utf-8")); + } + + @Test + void aMultipartRequestKeepsItsQueryParametersInTheUrl() throws Exception { + // queryParam on a multipart builder goes in the URL; param is a form field. + mvc.perform(multipart("/api/echo").queryParam("q", "inUrl").param("a", "inForm")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.target").value("/api/echo?q=inUrl")) + .andExpect(jsonPath("$.params.q").value("inUrl")) + .andExpect(jsonPath("$.params.a").value("inForm")); + } + + @Test + void aJsonNullDoesNotExist() throws Exception { + // As in Spring: a null-valued property fails exists() and passes doesNotExist(). + mvc.perform(get("/api/json/map")) + .andExpect(jsonPath("$.nothing").doesNotExist()) + .andExpect(jsonPath("$.absent").doesNotExist()); + boolean existed; + try { + mvc.perform(get("/api/json/map")).andExpect(jsonPath("$.nothing").exists()); + existed = true; + } catch (AssertionError expected) { + existed = false; + } + assertTrue(!existed, "exists() passed for a JSON null"); + } + + @Test + void andExpectAllReportsAMatcherThatThrowsAndRunsTheRest() throws Exception { + // A Content-Type is no number: longValue throws, and the status check after + // it must still run and be reported with it. + String report; + try { + mvc.perform(get("/api/hello/{name}", "Ada")).andExpectAll( + header().longValue("Content-Type", 1), + status().is(418)); + report = null; + } catch (AssertionError expected) { + report = expected.getMessage(); + } + assertTrue(report != null, "two failed expectations passed"); + assertTrue(report.indexOf("NumberFormatException") >= 0, report); + assertTrue(report.indexOf("418") >= 0, "the expectation after the throw did not run: " + report); + } + + @Test + void anAbsentJsonPathIsNotEmpty() throws Exception { + boolean passed; + try { + mvc.perform(get("/api/json/map")).andExpect(jsonPath("$.absent").isEmpty()); + passed = true; + } catch (AssertionError expected) { + passed = false; + } + assertTrue(!passed, "isEmpty() passed for a path that is not there"); + mvc.perform(get("/api/json/map")).andExpect(jsonPath("$.nothing").isEmpty()); + } + + @Test + void aPassingAssertThrowsNeverAsksForItsMessage() { + // JUnit builds the message only for a failure; compiled must too. + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> { throw new IllegalStateException("expected"); }, + () -> { throw new AssertionError("the message supplier ran for a passing assertion"); }); + } + + @Test + void nestedIterablesCompareByIteration() { + java.util.List lists = new java.util.ArrayList(); + lists.add(java.util.Arrays.asList("a", "b")); + java.util.List sets = new java.util.ArrayList(); + sets.add(new java.util.LinkedHashSet(java.util.Arrays.asList("a", "b"))); + org.junit.jupiter.api.Assertions.assertIterableEquals(lists, sets); + } + + @Test + void assertAllRethrowsAnOutOfMemoryError() { + org.junit.jupiter.api.Assertions.assertThrows(OutOfMemoryError.class, + () -> org.junit.jupiter.api.Assertions.assertAll( + () -> { throw new OutOfMemoryError("simulated"); }, + () -> { throw new AssertionError("ran after an unrecoverable error"); })); + } + + @Test + void assertDoesNotThrowRethrowsAnOutOfMemoryError() { + org.junit.jupiter.api.Assertions.assertThrows(OutOfMemoryError.class, + () -> org.junit.jupiter.api.Assertions.assertDoesNotThrow( + (org.junit.jupiter.api.function.Executable) + () -> { throw new OutOfMemoryError("simulated"); })); + } + + @Test + void anInvalidDeltaIsRefusedBeforeComparing() { + // Both runs fail on the delta itself, equal operands or not. + org.opentest4j.AssertionFailedError negative = org.junit.jupiter.api.Assertions.assertThrows( + org.opentest4j.AssertionFailedError.class, + () -> org.junit.jupiter.api.Assertions.assertEquals(1.0, 1.0, -1.0)); + assertTrue(negative.getMessage().indexOf("positive delta expected") >= 0, negative.getMessage()); + org.junit.jupiter.api.Assertions.assertThrows(org.opentest4j.AssertionFailedError.class, + () -> org.junit.jupiter.api.Assertions.assertEquals(1.0f, 1.0f, Float.NaN)); + } + + @Test + void nestedPrimitiveArraysCompareByContent() { + // The rows of an int[][] are compared by content, compiled as on the JVM. + assertArrayEquals(new int[][] {{1, 2}, {3}}, new int[][] {{1, 2}, {3}}); + assertArrayEquals(new double[][] {{0.5}}, new double[][] {{0.5}}); + } + + @Test + void bodiesTheWireOmitsAreOmitted() throws Exception { + // A 205 carries no body on the wire, though this handler builds "status 205". + mvc.perform(get("/api/status/205")).andExpect(status().is(205)) + .andExpect(content().string("")); + } + + @Test + void statusesAndRedirects() throws Exception { + mvc.perform(get("/api/status/418")).andExpect(status().is(418)) + .andExpect(content().string("status 418")); + mvc.perform(get("/api/status/204")).andExpect(status().isNoContent()); + mvc.perform(get("/api/redirect/2")).andExpect(status().isFound()) + .andExpect(redirectedUrl("/api/redirect/1")); + mvc.perform(get("/api/redirect/0")).andExpect(content().string("landed")); + } + + @Test + void cookiesAndAuthentication() throws Exception { + mvc.perform(get("/api/cookie/set").param("name", "flavor").param("value", "oat")) + .andExpect(cookie().value("flavor", "oat")); + mvc.perform(get("/api/cookie/read").param("name", "flavor").cookie("flavor", "rye")) + .andExpect(content().string("rye")); + mvc.perform(get("/api/auth/basic")).andExpect(status().isUnauthorized()) + .andExpect(header().exists("WWW-Authenticate")); + mvc.perform(get("/api/auth/basic").header("Authorization", ProbeApi.BASIC)) + .andExpect(content().string("user")); + mvc.perform(get("/api/auth/bearer").header("Authorization", ProbeApi.TOKEN)) + .andExpect(status().isOk()); + } + + @Test + void uploadsAndRawBodies() throws Exception { + byte[] file = {0, 1, 2, (byte) 0xff, 13, 10}; + mvc.perform(multipart("/api/upload") + .file(new MockMultipartFile("file", "probe.bin", "application/octet-stream", + file)) + .param("note", "hello")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.filename").value("probe.bin")) + .andExpect(jsonPath("$.size").value(6)) + .andExpect(jsonPath("$.sum").value(ProbeApi.checksum(file))) + .andExpect(jsonPath("$.note").value("hello")); + mvc.perform(post("/api/raw").contentType("application/octet-stream").content(file)) + .andExpect(jsonPath("$.size").value(6)); + } + + @Test + void largeAnswersAreGzippedWhenAsked() throws Exception { + mvc.perform(get("/api/big").param("size", "8192").header("Accept-Encoding", "gzip")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Encoding", "gzip")); + String plain = mvc.perform(get("/api/big").param("size", "8192")) + .andExpect(header().doesNotExist("Content-Encoding")) + .andReturn().getResponse().getContentAsString(); + assertEquals(8192, plain.length()); + } + + @Test + void corsAnswersAPreflight() throws Exception { + mvc.perform(options("/api/echo").header("Origin", "http://127.0.0.1:9000") + .header("Access-Control-Request-Method", "POST")) + .andExpect(status().isNoContent()) + .andExpect(header().string("Access-Control-Allow-Origin", "*")); + String exposed = mvc.perform(get("/api/headers").header("Origin", "http://127.0.0.1:9000")) + .andReturn().getResponse().getHeader("Access-Control-Expose-Headers"); + assertTrue(exposed != null && exposed.indexOf("X-Probe") >= 0, exposed); + } + + @Test + void jsonShapes() throws Exception { + mvc.perform(get("/api/json/list")) + .andExpect(jsonPath("$.length()").value(7)) + .andExpect(jsonPath("$[5]").value("six")); + mvc.perform(get("/api/json/map")) + .andExpect(jsonPath("$.nested.depth").value(2)) + .andExpect(jsonPath("$.nested.tags[0]").value(1)) + .andExpect(jsonPath("$.ratio").value(0.5)) + .andExpect(content().json("{\"title\":\"probe\",\"count\":3}")); + } +} diff --git a/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ServedTest.java b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ServedTest.java new file mode 100644 index 00000000000..3ec5b48b7b6 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ServedTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.backend; + +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.HttpStatus; +import com.codename1.backend.test.LocalServerPort; +import com.codename1.backend.test.ResponseEntity; +import com.codename1.backend.test.TestRestTemplate; +import com.codename1.backend.annotations.Autowired; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The server on a real port, through the backend's own HTTP client: what a +/// compiled run sends from the native binary. +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class ServedTest { + @Autowired + private TestRestTemplate rest; + + @LocalServerPort + private int port; + + @Test + void servesOnARandomPort() throws Exception { + assertTrue(port > 0 && port != 8765, "port " + port); + assertEquals("Hello, Ada", rest.getForObject("/api/hello/{name}", String.class, "Ada")); + ResponseEntity missing = rest.getForEntity("/api/pets/999", String.class); + assertEquals(HttpStatus.NOT_FOUND, missing.getStatusCode()); + } + + @Test + void postsJsonAndReadsItBack() throws Exception { + Map pet = new LinkedHashMap(); + pet.put("name", "Tom"); + pet.put("species", "cat"); + pet.put("age", Long.valueOf(2)); + ResponseEntity created = rest.postForEntity("/api/pets", pet, Map.class); + assertEquals(201, created.getStatusCodeValue()); + assertEquals("Tom", created.getBody().get("name")); + List all = rest.getForObject("/api/pets", List.class); + assertTrue(all.size() >= 1, String.valueOf(all)); + } + + @Test + void headersAndBasicAuth() throws Exception { + ResponseEntity answer = rest.getForEntity("/api/headers", String.class); + assertEquals("one", answer.getHeaders().getFirst("X-Probe")); + assertEquals(401, rest.getForEntity("/api/auth/basic", String.class).getStatusCodeValue()); + assertEquals("user", rest.withBasicAuth("user", "pass") + .getForObject("/api/auth/basic", String.class)); + } +} diff --git a/scripts/hellocodenameone/common/codenameone_settings.properties b/scripts/hellocodenameone/common/codenameone_settings.properties index 7ff4237d7b4..4e1fe0f487c 100644 --- a/scripts/hellocodenameone/common/codenameone_settings.properties +++ b/scripts/hellocodenameone/common/codenameone_settings.properties @@ -41,3 +41,6 @@ codename1.tvMain=com.codenameone.examples.hellocodenameone.HelloCodenameOne codename1.vendor=CodenameOne codename1.version=1.0 codename1.watchMain=com.codenameone.examples.hellocodenameone.HelloCodenameOneWatch +# The backend networking tests reach the CI server over plain HTTP at 10.0.2.2, +# which Android blocks by default since API 28. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 26263987f9b..ef733e70d77 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -22,6 +22,11 @@ */ package com.codenameone.examples.hellocodenameone.tests; +import com.codenameone.examples.hellocodenameone.tests.backend.BackendClientTest; +import com.codenameone.examples.hellocodenameone.tests.backend.BackendConnectionRequestTest; +import com.codenameone.examples.hellocodenameone.tests.backend.BackendRestTest; +import com.codenameone.examples.hellocodenameone.tests.backend.BackendTransferTest; +import com.codenameone.examples.hellocodenameone.tests.backend.BackendTypedClientTest; import com.codename1.testing.DeviceRunner; import com.codename1.testing.TestReporting; import com.codename1.ui.CN; @@ -114,6 +119,14 @@ private static int testTimeoutMs(BaseTest testClass) { && testClass instanceof DualAppearanceBaseTest) { return TEST_TIMEOUT_MS_NATIVE * 2; } + if (testClass instanceof BackendClientTest) { + // A backend client test fails a step that hears nothing after + // BackendClientTest.STEP_TIMEOUT_MILLIS, naming the request. That only + // works inside this budget, and the 10s HTML5 default is shorter than + // one step's watchdog -- so a dropped request on the browser came out + // as an anonymous "timeout waiting for DONE" instead. + return BackendClientTest.STEP_TIMEOUT_MILLIS * 2 + TEST_TIMEOUT_MS_HTML5; + } if (!"HTML5".equals(Display.getInstance().getPlatformName()) && testClass instanceof LightweightPickerButtonsScreenshotTest) { return 45000; @@ -525,6 +538,15 @@ private static int testTimeoutMs(BaseTest testClass) { new FloatingToStringTest(), new StringFormatTest(), new ClipboardRoundTripTest(), + // The app's networking APIs against the CI's own backend + // (scripts/hellocodenameone/backend), served by the same process that + // receives these screenshots: Rest, the generated @RestClient, + // ConnectionRequest, MultipartRequest, downloads and gzip. + // Assertion-only. + new BackendRestTest(), + new BackendTypedClientTest(), + new BackendConnectionRequestTest(), + new BackendTransferTest(), // The contact picker's request contract, and the only thing in // this suite that references com.codename1.contacts.ContactPicker // -- which is what makes the iOS build compile and link its diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java index fa9eaa354f5..a2707a5c9df 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunnerHelper.java @@ -39,7 +39,8 @@ import java.util.Map; /// Device-side helper that ships screenshots to the host over a single -/// transport: a WebSocket to the host-side Cn1ssScreenshotServer. The device +/// transport: a WebSocket to the host-side test server +/// (scripts/hellocodenameone/backend). The device /// connects to ws://HOST:8765, sends a JSON META text frame followed by the /// binary PNG, and the host writes the file and echoes an ACK. Native ports /// use the blocking, ACK-paced sink (Cn1ssWebSocketSink.trySend); the JS port @@ -49,8 +50,8 @@ /// or a native send remains unacknowledged after its bounded retry, the runner /// retries that one test once. interface Cn1ssDeviceRunnerHelper { - // Standard, fixed port the host-side Cn1ssScreenshotServer listens on - // (scripts/lib/cn1ss.sh starts it with --port 8765). The runner does not + // Standard, fixed port the host-side test server listens on + // (scripts/lib/cn1ss.sh starts it on 8765). The runner does not // inject the URL per-run; the device defaults to ws://HOST:8765 below so // no platform-specific env/property plumbing is needed. Keep this value in // sync with CN1SS_WS_PORT in scripts/lib/cn1ss.sh. @@ -676,7 +677,7 @@ private static boolean ensureConnected() { // A -Dcn1ss.websocket.url override still wins where the launcher can // set Display properties (e.g. the JavaSE simulator via the maven // plugin's -Dproperty=...). Everywhere else we don't inject anything: - // the host runs Cn1ssScreenshotServer on the fixed standard port and + // the host runs the test server on the fixed standard port and // the device defaults to ws://HOST:CN1SS_WS_DEFAULT_PORT. HOST is the // host loopback as seen from the app -- the Android emulator reaches // it via 10.0.2.2, every other target (iOS simulator, Mac Catalyst, diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendClientTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendClientTest.java new file mode 100644 index 00000000000..de765f56f46 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendClientTest.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import com.codename1.ui.Display; +import com.codenameone.examples.hellocodenameone.tests.BaseTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// The base of the tests that drive the CI's backend +/// (scripts/hellocodenameone/backend) with the app's networking APIs. +/// +/// The backend is the same process that receives the screenshots, on the same +/// port, so a leg that runs the suite has it: 10.0.2.2:8765 from the Android +/// emulator, 127.0.0.1:8765 everywhere else, `-Dcn1ss.backend.url` to override. +/// A request that cannot reach it FAILS the test -- an unreachable server is a +/// broken leg, not a reason to skip. +/// +/// Every call is asynchronous, so a test is a list of steps: each step sends one +/// request and its callback checks the answer and moves on with [#proceed]. A +/// synchronous call would block the EDT, which the browser port cannot do. +/// +/// A step that never hears back FAILS, naming itself, after [#STEP_TIMEOUT_MILLIS]. +/// Without that, a request a port drops -- no callback of any kind -- left the +/// suite's per-test timeout to report "timeout waiting for DONE", which says +/// nothing about which request or why. +public abstract class BackendClientTest extends BaseTest { + /// How long one step may wait for its answer. Under the runner's per-test + /// budget, and well over the slowest step (a deliberate four-second delay). + public static final int STEP_TIMEOUT_MILLIS = 12000; + + /// Marks a request that reports its own failures through `handleException`. + /// The application's network error listener (`Lifecycle.handleNetworkError`) + /// consumes every error it sees, and `NetworkManager` calls a request's own + /// handler only when no listener consumed the error -- so a step expecting a + /// failure (the timeout probe) never heard back and a modal "Connection Error" + /// dialog was left over the screens of later tests. The application leaves + /// requests carrying this marker alone. + public interface ReportsOwnErrors { + } + + private final List steps = new ArrayList(); + private int current = -1; + private java.util.Timer watchdog; + + /// The backend's base URL, with no trailing slash. + public static String baseUrl() { + String url = Display.getInstance().getProperty("cn1ss.backend.url", ""); + if (url == null || url.length() == 0) { + String host = "and".equals(Display.getInstance().getPlatformName()) + ? "10.0.2.2" : "127.0.0.1"; + url = "http://" + host + ":8765"; + } + while (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + /// `path` on the backend. + protected static String url(String path) { + return baseUrl() + path; + } + + /// Whether this is the browser port, whose cookies and redirects belong to the + /// browser rather than to Codename One. + protected static boolean isBrowser() { + return "HTML5".equals(Display.getInstance().getPlatformName()); + } + + /// Adds the steps of this test, in order. + protected abstract void defineSteps(); + + /// Adds one step. It must end -- in its callback -- with [#proceed] or a failure. + protected final void step(Runnable step) { + steps.add(step); + } + + @Override + public boolean runTest() { + steps.clear(); + current = -1; + defineSteps(); + proceed(-1); + return true; + } + + /// The index of the step running now, for [#proceed]. + protected final int currentStep() { + return current; + } + + /// Runs the step after `from`, once: a late or repeated callback for a step + /// that already moved on is ignored rather than running the rest twice. + protected final void proceed(int from) { + // Callbacks arrive on the EDT, one at a time, so no lock is needed here. + if (isFailed() || from != current) { + return; + } + current++; + Runnable next = current >= steps.size() ? null : steps.get(current); + if (next == null) { + stopWatchdog(); + done(); + return; + } + watch(current); + try { + next.run(); + } catch (Throwable err) { + stopWatchdog(); + fail("step " + (current + 1) + " threw " + err); + } + } + + /// Fails the test if step `step` is still the current one when its time is up. + private void watch(final int step) { + if (watchdog == null) { + watchdog = new java.util.Timer(); + } + watchdog.schedule(new java.util.TimerTask() { + @Override + public void run() { + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (!isFailed() && current == step) { + stopWatchdog(); + fail(BackendClientTest.this.getClass().getName() + " step " + (step + 1) + + " got no answer in " + STEP_TIMEOUT_MILLIS + "ms: the request " + + "was dropped without a response or an error"); + } + } + }); + } + }, STEP_TIMEOUT_MILLIS); + } + + private void stopWatchdog() { + if (watchdog != null) { + watchdog.cancel(); + watchdog = null; + } + } + + /// Fails the test, as [#fail] does, from any step's error path. + protected final void failStep(String message) { + stopWatchdog(); + fail(getClass().getName() + " step " + (currentStep() + 1) + ": " + message); + } + + /// Fails the test with `message` unless `condition` holds; true when it does. + protected final boolean expect(boolean condition, String message) { + if (!condition) { + failStep(message); + } + return condition; + } + + /// A JSON number as an int, whichever boxing the parser chose. + protected static int number(Object value) { + return value instanceof Number ? ((Number) value).intValue() : Integer.MIN_VALUE; + } + + /// A value of a parsed JSON object, or null. + protected static Object field(Object map, String key) { + return map instanceof Map ? ((Map) map).get(key) : null; + } + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + /// The callbacks outlive runTest(), which a retry would race. + @Override + public boolean isRetrySafe() { + return false; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendConnectionRequestTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendConnectionRequestTest.java new file mode 100644 index 00000000000..c7521587f7e --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendConnectionRequestTest.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.NetworkManager; + +import java.io.IOException; +import java.io.InputStream; + +/// `ConnectionRequest` itself against the backend: a raw body, response headers, +/// error statuses read rather than thrown, redirects followed and not, cookies and +/// a server session kept across requests, and a timeout. +public class BackendConnectionRequestTest extends BackendClientTest { + @Override + protected void defineSteps() { + step(() -> { + final int id = currentStep(); + Probe probe = new Probe(url("/api/echo"), "POST") { + @Override + protected void answered() { + if (expect(code == 200, "raw post answered " + code) + && expect(text().indexOf("\"body\":\"plain body\"") >= 0, + "raw post echoed " + text()) + && expect("echo".equals(probeHeader), "X-Probe was " + probeHeader)) { + proceed(id); + } + } + }; + probe.setContentType("text/plain"); + probe.setRequestBody("plain body"); + NetworkManager.getInstance().addToQueue(probe); + }); + step(() -> { + final int id = currentStep(); + NetworkManager.getInstance().addToQueue(new Probe(url("/api/status/503"), "GET") { + @Override + protected void answered() { + if (expect(code == 503, "503 read as " + code) + && expect("status 503".equals(text()), "503 body " + text())) { + proceed(id); + } + } + }); + }); + step(() -> { + final int id = currentStep(); + NetworkManager.getInstance().addToQueue(new Probe(url("/api/redirect/2"), "GET") { + @Override + protected void answered() { + if (expect(code == 200 && "landed".equals(text()), + "followed redirects to " + code + " " + text())) { + proceed(id); + } + } + }); + }); + if (!isBrowser()) { + // The browser follows a redirect before a page's code can see it, so + // not following one is something only the native ports can do. + step(() -> { + final int id = currentStep(); + Probe probe = new Probe(url("/api/redirect/1"), "GET") { + @Override + protected void answered() { + if (expect(code == 302, "an unfollowed redirect answered " + code)) { + proceed(id); + } + } + }; + probe.setFollowRedirects(false); + NetworkManager.getInstance().addToQueue(probe); + }); + // Cookies and the session they carry. On the browser both are the + // browser's: a cross-origin request carries no cookies unless the page + // asks for credentials, which is browser policy, not Codename One's. + step(() -> { + final int id = currentStep(); + NetworkManager.getInstance().addToQueue( + new Probe(url("/api/cookie/set?name=flavor&value=oat"), "GET") { + @Override + protected void answered() { + if (expect(code == 200, "cookie set answered " + code)) { + proceed(id); + } + } + }); + }); + step(() -> { + final int id = currentStep(); + NetworkManager.getInstance().addToQueue( + new Probe(url("/api/cookie/read?name=flavor"), "GET") { + @Override + protected void answered() { + if (expect("oat".equals(text()), "the cookie came back as " + text())) { + proceed(id); + } + } + }); + }); + step(() -> sessionCount(1)); + step(() -> sessionCount(2)); + } + step(() -> { + final int id = currentStep(); + Probe probe = new Probe(url("/api/slow?ms=4000"), "GET") { + @Override + protected void answered() { + expect(false, "a request past its timeout answered " + code + " " + text()); + } + + @Override + protected void failed(Exception err) { + proceed(id); + } + }; + probe.setTimeout(1000); + probe.setReadTimeout(1000); + NetworkManager.getInstance().addToQueue(probe); + }); + } + + private int sessionBase = -1; + + /// Asks for the session's counter: the first call fixes the base, the next must + /// be one more -- which only holds when the session cookie went back. + private void sessionCount(final int call) { + final int id = currentStep(); + NetworkManager.getInstance().addToQueue(new Probe(url("/api/session/count"), "GET") { + @Override + protected void answered() { + int value; + try { + value = Integer.parseInt(text().trim()); + } catch (NumberFormatException err) { + expect(false, "session count was " + text()); + return; + } + if (call == 1) { + sessionBase = value; + proceed(id); + } else if (expect(value == sessionBase + 1, "the session counter went from " + + sessionBase + " to " + value + ": the session cookie was not sent")) { + proceed(id); + } + } + }); + } + + /// A request that reads its answer whatever the status, and reports once. An + /// inner class, so a request that fails reports through the test instead of + /// throwing on a thread nothing catches -- which left the test hanging. + abstract class Probe extends ConnectionRequest implements ReportsOwnErrors { + int code; + byte[] data; + String probeHeader; + private boolean reported; + + Probe(String url, String method) { + setUrl(url); + setHttpMethod(method); + setPost(!"GET".equals(method) && !"HEAD".equals(method) && !"DELETE".equals(method)); + setReadResponseForErrors(true); + // NOT fail-silent: NetworkManager hands a fail-silent request's exception + // to nobody -- not even an overridden handleException -- so a timeout or + // a refused connection ended the request with no callback at all. The + // overrides below are what keep the default error dialog away. + setDuplicateSupported(true); + } + + @Override + protected void readHeaders(Object connection) throws IOException { + probeHeader = getHeader(connection, "X-Probe"); + } + + @Override + protected void handleErrorResponseCode(int responseCode, String message) { + code = responseCode; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + if (code == 0) { + code = getResponseCode(); + } + data = com.codename1.io.Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (code == 0) { + code = getResponseCode(); + } + if (!reported) { + reported = true; + answered(); + } + } + + @Override + protected void handleException(final Exception err) { + // Called on the NETWORK thread, unlike postResponse: the step runner + // and the test's completion belong on the EDT. Android tolerated the + // network-thread call; the browser port left the test unfinished. + com.codename1.ui.CN.callSerially(new Runnable() { + public void run() { + if (!reported) { + reported = true; + failed(err); + } + } + }); + } + + String text() { + try { + return data == null ? "" : new String(data, "UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + return ""; + } + } + + protected abstract void answered(); + + protected void failed(Exception err) { + failStep("request to " + getUrl() + " failed: " + err); + } + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendRestTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendRestTest.java new file mode 100644 index 00000000000..fe6fd53a9ae --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendRestTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import com.codename1.io.rest.Rest; + +import java.util.Map; + +/// `com.codename1.io.rest.Rest` against the backend: every verb, query parameters +/// and headers, JSON in and out, HTTP Basic and bearer authentication, and an +/// error status delivered to its handler. +public class BackendRestTest extends BackendClientTest { + @Override + protected void defineSteps() { + step(() -> { + final int id = currentStep(); + Rest.get(url("/api/hello/Ada")).fetchAsString(r -> { + if (expect(r.getResponseCode() == 200, "hello answered " + r.getResponseCode()) + && expect("Hello, Ada".equals(r.getResponseData()), + "hello said " + r.getResponseData())) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + Rest.get(url("/api/echo")).queryParam("a", "1").queryParam("b", "two words") + .header("X-Test", "yes").acceptJson().fetchAsJsonMap(r -> { + Map echo = r.getResponseData(); + Object params = field(echo, "params"); + Object headers = field(echo, "headers"); + if (expect("GET".equals(field(echo, "method")), "echo saw " + echo) + && expect("1".equals(field(params, "a")), "a was " + params) + && expect("two words".equals(field(params, "b")), "b was " + params) + && expect("yes".equals(field(headers, "X-Test")), + "X-Test was " + headers)) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + Rest.post(url("/api/echo")).jsonContent().body("{\"hello\":\"world\"}") + .fetchAsJsonMap(r -> { + Map echo = r.getResponseData(); + Object type = field(field(echo, "headers"), "Content-Type"); + if (expect("POST".equals(field(echo, "method")), "post echo " + echo) + && expect("{\"hello\":\"world\"}".equals(field(echo, "body")), + "post body " + field(echo, "body")) + && expect(String.valueOf(type).indexOf("application/json") >= 0, + "post content type " + type)) { + proceed(id); + } + }); + }); + verb("PUT"); + verb("PATCH"); + verb("DELETE"); + step(() -> { + final int id = currentStep(); + Rest.get(url("/api/json/map")).acceptJson().fetchAsJsonMap(r -> { + Map m = r.getResponseData(); + Object nested = field(m, "nested"); + if (expect("probe".equals(field(m, "title")), "title " + m) + && expect(number(field(m, "count")) == 3, "count " + field(m, "count")) + && expect(number(field(nested, "depth")) == 2, "nested " + nested)) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + Rest.get(url("/api/auth/basic")).basicAuth("user", "pass").fetchAsString(r -> { + if (expect("user".equals(r.getResponseData()), "basic auth answered " + + r.getResponseCode() + " " + r.getResponseData())) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + Rest.get(url("/api/auth/bearer")).bearer("token-123").fetchAsString(r -> { + if (expect(r.getResponseCode() == 200, "bearer answered " + r.getResponseCode())) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + Rest.get(url("/api/status/418")).onErrorCodeString(err -> { + if (expect(err.getResponseCode() == 418, "error handler got " + + err.getResponseCode())) { + proceed(id); + } + }).fetchAsString(r -> expect(false, "a 418 reached the success callback: " + + r.getResponseCode())); + }); + } + + /// One verb against the echo route, checking the server saw it. + private void verb(final String method) { + step(() -> { + final int id = currentStep(); + com.codename1.io.rest.RequestBuilder builder = "PUT".equals(method) + ? Rest.put(url("/api/echo")) : "PATCH".equals(method) + ? Rest.patch(url("/api/echo")) : Rest.delete(url("/api/echo")); + if (!"DELETE".equals(method)) { + builder.jsonContent().body("{\"verb\":\"" + method + "\"}"); + } + builder.fetchAsJsonMap(r -> { + Map echo = r.getResponseData(); + if (expect(method.equals(field(echo, "method")), method + " echo saw " + echo)) { + proceed(id); + } + }); + }); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTransferTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTransferTest.java new file mode 100644 index 00000000000..10d8e979688 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTransferTest.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.FileSystemStorage; +import com.codename1.io.MultipartRequest; +import com.codename1.io.NetworkManager; +import com.codename1.io.Util; +import com.codename1.io.gzip.GZConnectionRequest; + +import java.io.IOException; +import java.io.InputStream; + +/// Moving bytes: a multipart upload with a form field, a raw binary body, a +/// download straight to a file, and a gzipped answer decoded. +public class BackendTransferTest extends BackendClientTest { + private static final int DOWNLOAD = 100000; + + @Override + protected void defineSteps() { + step(() -> { + final int id = currentStep(); + final byte[] file = new byte[3000]; + for (int iter = 0 ; iter < file.length ; iter++) { + file[iter] = (byte) (iter * 7); + } + MultipartRequest upload = new MultipartRequest() { + @Override + protected void readResponse(InputStream input) throws IOException { + String json = new String(Util.readInputStream(input), "UTF-8"); + if (expect(json.indexOf("\"size\":3000") >= 0, "upload saw " + json) + && expect(json.indexOf("\"sum\":" + checksum(file)) >= 0, + "upload checksum " + json) + && expect(json.indexOf("\"filename\":\"probe.bin\"") >= 0, + "upload filename " + json) + && expect(json.indexOf("\"note\":\"hello\"") >= 0, "upload note " + json)) { + proceed(id); + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + expect(false, "upload answered " + code + " " + message); + } + + @Override + protected void handleException(final Exception err) { + // On the network thread; fail on the EDT like every other step. + com.codename1.ui.CN.callSerially(new Runnable() { + public void run() { + expect(false, "upload failed: " + err); + } + }); + } + }; + upload.setUrl(url("/api/upload")); + upload.addData("file", file, "application/octet-stream"); + upload.setFilename("file", "probe.bin"); + upload.addArgument("note", "hello"); + NetworkManager.getInstance().addToQueue(upload); + }); + step(() -> { + final int id = currentStep(); + final byte[] raw = {0, 1, 2, (byte) 0xfe, (byte) 0xff, 13, 10}; + ConnectionRequest post = new ConnectionRequest() { + @Override + protected void buildRequestBody(java.io.OutputStream os) throws IOException { + os.write(raw); + } + + @Override + protected void readResponse(InputStream input) throws IOException { + String json = new String(Util.readInputStream(input), "UTF-8"); + if (expect(json.indexOf("\"size\":7") >= 0 + && json.indexOf("\"sum\":" + checksum(raw)) >= 0, + "raw upload saw " + json)) { + proceed(id); + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + expect(false, "raw upload answered " + code + " " + message); + } + }; + post.setUrl(url("/api/raw")); + post.setPost(true); + post.setContentType("application/octet-stream"); + NetworkManager.getInstance().addToQueue(post); + }); + step(() -> { + final int id = currentStep(); + FileSystemStorage fs = FileSystemStorage.getInstance(); + String home = fs.getAppHomePath(); + final String path = (home.endsWith("/") ? home : home + "/") + "backend-download.bin"; + if (fs.exists(path)) { + fs.delete(path); + } + ConnectionRequest download = new ConnectionRequest() { + @Override + protected void postResponse() { + try { + InputStream in = FileSystemStorage.getInstance().openInputStream(path); + byte[] got = Util.readInputStream(in); + in.close(); + boolean pattern = got.length == DOWNLOAD; + for (int iter = 0 ; pattern && iter < got.length ; iter++) { + pattern = got[iter] == (byte) iter; + } + if (expect(pattern, "the downloaded file was " + got.length + + " bytes or had the wrong content")) { + proceed(id); + } + } catch (IOException err) { + expect(false, "could not read the downloaded file: " + err); + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + expect(false, "download answered " + code + " " + message); + } + }; + download.setUrl(url("/api/download/" + DOWNLOAD)); + download.setPost(false); + download.setDestinationFile(path); + NetworkManager.getInstance().addToQueue(download); + }); + step(() -> { + final int id = currentStep(); + GZConnectionRequest gz = new GZConnectionRequest() { + @Override + protected void readUnzipedResponse(InputStream input) throws IOException { + String text = new String(Util.readInputStream(input), "UTF-8"); + if (expect(text.length() == 20000 && text.startsWith("Codename One backend probe. "), + "the gzipped answer decoded to " + text.length() + " characters")) { + proceed(id); + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + expect(false, "gzip answered " + code + " " + message); + } + }; + gz.setUrl(url("/api/big?size=20000")); + gz.setPost(false); + NetworkManager.getInstance().addToQueue(gz); + }); + } + + /// The backend's checksum: a 31-based polynomial over the bytes. + static long checksum(byte[] data) { + long sum = 0; + for (int iter = 0 ; iter < data.length ; iter++) { + sum = (sum * 31 + (data[iter] & 0xff)) & 0xffffffffL; + } + return sum; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTypedClientTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTypedClientTest.java new file mode 100644 index 00000000000..aa4ff384106 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTypedClientTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import java.util.List; + +/// The generated `@RestClient` against the backend's typed API: DTOs out and in, +/// path and query parameters, a header, a list, and the whole create-read-update- +/// delete cycle. The app and the server declare their own classes, so this is +/// where a field one side renamed shows up. +public class BackendTypedClientTest extends BackendClientTest { + private PetsApi api; + private Long created; + + @Override + protected void defineSteps() { + api = PetsApi.of(baseUrl()); + created = null; + step(() -> { + final int id = currentStep(); + api.hello("Grace", r -> { + if (expect(r != null && "Hello, Grace".equals(r.getResponseData()), + "typed hello answered " + (r == null ? null : r.getResponseData()))) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + api.create(new PetDto("Rex", "dog", 3), r -> { + PetDto pet = r == null ? null : r.getResponseData(); + if (expect(r != null && r.getResponseCode() == 201, "create answered " + + (r == null ? null : Integer.valueOf(r.getResponseCode()))) + && expect(pet != null && pet.id != null && "Rex".equals(pet.name), + "created " + describe(pet))) { + created = pet.id; + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + api.get(created, r -> { + PetDto pet = r == null ? null : r.getResponseData(); + if (expect(pet != null && "dog".equals(pet.species) + && pet.age != null && pet.age.intValue() == 3, + "read back " + describe(pet))) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + PetDto older = new PetDto("Rex", "dog", 4); + older.vaccinated = Boolean.TRUE; + api.replace(created, older, r -> { + PetDto pet = r == null ? null : r.getResponseData(); + if (expect(pet != null && pet.age != null && pet.age.intValue() == 4 + && Boolean.TRUE.equals(pet.vaccinated), "replaced " + describe(pet))) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + api.list(r -> { + List pets = r == null ? null : r.getResponseData(); + boolean found = false; + for (int iter = 0 ; pets != null && iter < pets.size() ; iter++) { + found |= created.equals(pets.get(iter).id); + } + if (expect(found, "the list did not hold pet " + created)) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + api.echo("typed", "header-value", r -> { + String body = r == null ? null : r.getResponseData(); + if (expect(body != null && body.indexOf("\"q\":\"typed\"") >= 0 + && body.indexOf("header-value") >= 0, "typed echo " + body)) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + api.delete(created, r -> { + if (expect(r != null && r.getResponseCode() == 204, "delete answered " + + (r == null ? null : Integer.valueOf(r.getResponseCode())))) { + proceed(id); + } + }); + }); + step(() -> { + final int id = currentStep(); + api.get(created, r -> { + if (expect(r != null && r.getResponseCode() == 404, "a deleted pet answered " + + (r == null ? null : Integer.valueOf(r.getResponseCode())))) { + proceed(id); + } + }); + }); + } + + private static String describe(PetDto pet) { + return pet == null ? "null" : "{id=" + pet.id + ", name=" + pet.name + ", species=" + + pet.species + ", age=" + pet.age + ", vaccinated=" + pet.vaccinated + "}"; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetDto.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetDto.java new file mode 100644 index 00000000000..c55b768fa95 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetDto.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import com.codename1.annotations.Mapped; + +/// The app's copy of the backend's Pet: the same fields, mapped to and from JSON by +/// the generated mapper. Kept separate from the server's class on purpose -- the +/// app and the server share a wire format, not a jar -- and the typed client test +/// is what holds the two together. +@Mapped +public class PetDto { + public Long id; + public String name; + public String species; + public Integer age; + public Boolean vaccinated; + + public PetDto() { + } + + public PetDto(String name, String species, int age) { + this.name = name; + this.species = species; + this.age = Integer.valueOf(age); + this.vaccinated = Boolean.FALSE; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetsApi.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetsApi.java new file mode 100644 index 00000000000..93cf4fe26ca --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetsApi.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests.backend; + +import com.codename1.annotations.rest.Body; +import com.codename1.annotations.rest.DELETE; +import com.codename1.annotations.rest.GET; +import com.codename1.annotations.rest.Header; +import com.codename1.annotations.rest.POST; +import com.codename1.annotations.rest.PUT; +import com.codename1.annotations.rest.Path; +import com.codename1.annotations.rest.Query; +import com.codename1.annotations.rest.RestClient; +import com.codename1.io.rest.Response; +import com.codename1.util.OnComplete; + +import java.util.List; + +/// The backend's typed API, as the app declares it: the build generates the client. +@RestClient +public interface PetsApi { + @GET("/api/pets") + void list(OnComplete>> callback); + + @GET("/api/pets/{id}") + void get(@Path("id") Long id, OnComplete> callback); + + @POST("/api/pets") + void create(@Body PetDto pet, OnComplete> callback); + + @PUT("/api/pets/{id}") + void replace(@Path("id") Long id, @Body PetDto pet, OnComplete> callback); + + @DELETE("/api/pets/{id}") + void delete(@Path("id") Long id, OnComplete> callback); + + @GET("/api/hello/{name}") + void hello(@Path("name") String name, OnComplete> callback); + + @GET("/api/echo") + void echo(@Query("q") String query, @Header("X-Test") String header, + OnComplete> callback); + + static PetsApi of(String baseUrl) { + return com.codename1.io.rest.RestClients.create(PetsApi.class, baseUrl); + } +} diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt index 539684f7040..67556387a4c 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt @@ -31,6 +31,7 @@ import com.codename1.car.CarListTemplate import com.codename1.car.CarRow import com.codename1.car.CarScreen import com.codename1.car.CarTemplate +import com.codename1.io.NetworkEvent import com.codename1.system.Lifecycle import com.codename1.testing.TestReporting import com.codename1.ui.CN @@ -38,12 +39,24 @@ import com.codename1.ui.Display import com.codenameone.examples.hellocodenameone.tests.Cn1ssDeviceRunner import com.codenameone.examples.hellocodenameone.tests.Cn1ssDeviceRunnerReporter import com.codenameone.examples.hellocodenameone.tests.KotlinUiTest +import com.codenameone.examples.hellocodenameone.tests.backend.BackendClientTest import com.codename1.annotations.buildhints.* @Android(useAndroidX = Toggle.ON) @Ios(applicationQueriesSchemes = ["cydia"], newStorageLocation = Toggle.ON) @IosPrivacy(cameraUsageDescription = "Used by the CI smoke test to verify the com.codename1.camera native bridge compiles. The app never opens a camera session.", healthShareUsageDescription = "Used by the CI smoke test to verify the com.codename1.health native bridge compiles. The app never reads real health data.", healthUpdateUsageDescription = "Used by the CI smoke test to verify the com.codename1.health write path compiles. The app never writes real health data.") open class HelloCodenameOne : Lifecycle() { + // Lifecycle's handler consumes every network error and shows a modal dialog, + // and NetworkManager then never calls the request's own handleException. A + // backend client test that expects a failure (a request past its timeout) + // reports it there, so leave its requests alone. + override fun handleNetworkError(err: NetworkEvent) { + if (err.connectionRequest is BackendClientTest.ReportsOwnErrors) { + return + } + super.handleNetworkError(err) + } + override fun init(context: Any?) { super.init(context) check(!Display.getInstance().isJailbrokenDevice()) { diff --git a/scripts/hellocodenameone/pom.xml b/scripts/hellocodenameone/pom.xml index a5c3e391af6..a7a4b28548b 100644 --- a/scripts/hellocodenameone/pom.xml +++ b/scripts/hellocodenameone/pom.xml @@ -90,27 +90,60 @@ codenameone-maven-plugin ${cn1.plugin.version} - - - org.apache.maven.plugins - maven-surefire-plugin - 3.0.0-M5 - - - com.codenameone - codenameone-maven-plugin - ${cn1.plugin.version} - - - + + + cn1-client-tests + + + codename1.platform + !backend + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + + backend + + + codename1.platform + backend + + + + backend + + javascript diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index cb7723c1c27..d27556b8359 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -478,6 +478,12 @@ private Map collectMavenBackendOnlyEntries() throws IOException entries, ZipEntryType.COMMON); copySingleTextEntryToMap(sourceDir + "Greeter.java", mavenBackendTemplate(scaffold, "backend/Greeter.java.txt"), entries, ZipEntryType.COMMON); + // The same @BackendTest samples every other backend layout gets. + String testDir = "src/test/java/" + packageName.replace('.', '/') + "/"; + copySingleTextEntryToMap(testDir + "ApiTest.java", + mavenBackendTemplate(scaffold, "backend/ApiTest.java.txt"), entries, ZipEntryType.COMMON); + copySingleTextEntryToMap(testDir + "ServedApiTest.java", + mavenBackendTemplate(scaffold, "backend/ServedApiTest.java.txt"), entries, ZipEntryType.COMMON); copySingleTextEntryToMap("README.md", buildMavenBackendReadmeMarkdown(), entries, ZipEntryType.COMMON); validateGeneratedPomCoordinates(entries); return entries; @@ -493,6 +499,9 @@ private String mavenBackendTemplate(Map scaffold, String name) t String text = StringUtil.newString(data); text = StringUtil.replaceAll(text, "./gradlew __BACKEND__runBackend", "./mvnw cn1:backend"); text = StringUtil.replaceAll(text, "./gradlew __BACKEND__backendPackage", "./mvnw cn1:backend-package"); + text = StringUtil.replaceAll(text, "./gradlew __BACKEND__backendTest", + "./mvnw test -Dcn1.backend.compiledTests=true"); + text = StringUtil.replaceAll(text, "./gradlew __BACKEND__test", "./mvnw test"); text = StringUtil.replaceAll(text, "under `runBackend`", "under `cn1:backend`"); text = StringUtil.replaceAll(text, "${package}", packageName); return text; @@ -510,6 +519,10 @@ private void addGradleBackendEntries(Map entries, Map entries, String path, String content) throws IOException { diff --git a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md index 438944bf512..d51edba6ad3 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md @@ -26,7 +26,7 @@ their own conventions; the canonical source of truth is `.agent-skills/`. - Native cloud builds use `mvn package -Dcodename1.platform= -Dcodename1.buildTarget=...` from the project root. A platform with no module of its own is built from `common/`. - The server side, when the project has one, lives in `backend/` (Spring-style - `@RestController` / `@Service`, resolved at build time). Run it with + `@RestController` / `@Component`, resolved at build time). Run it with `CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend`; it then serves MCP tools at `http://127.0.0.1:8080/mcp` for inspecting and exercising it. See `.agent-skills/codename-one/references/backend.md`, and diff --git a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md index 4afe68eaf57..dba72f48327 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-claude-stub.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven or Gradle, ParparVM/Android/iOS/JavaScript). Use when the project contains a `codenameone_settings.properties` (in `common/` for Maven, at the root for Gradle), a `settings.gradle.kts` applying the `com.codenameone` plugin, depends on `com.codenameone:codenameone-core`, edits CSS files under `src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build` or `./gradlew run`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` project (`cn1:backend`, `./gradlew runBackend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML/Android. +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven or Gradle, ParparVM/Android/iOS/JavaScript). Use when the project contains a `codenameone_settings.properties` (in `common/` for Maven, at the root for Gradle), a `settings.gradle.kts` applying the `com.codenameone` plugin, depends on `com.codenameone:codenameone-core`, edits CSS files under `src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build` or `./gradlew run`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` project (`cn1:backend`, `./gradlew runBackend`, `com.codename1.backend.*`, `@RestController`, `@Component`, `@Transactional`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML/Android. metadata: type: skill --- diff --git a/scripts/initializr/common/src/main/resources/backend-only-pom.xml b/scripts/initializr/common/src/main/resources/backend-only-pom.xml index 47641ebdcbc..a09299a8070 100644 --- a/scripts/initializr/common/src/main/resources/backend-only-pom.xml +++ b/scripts/initializr/common/src/main/resources/backend-only-pom.xml @@ -53,6 +53,24 @@ 3.46.1.0 runtime + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + @@ -80,8 +98,38 @@ process-annotations + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + diff --git a/scripts/initializr/common/src/main/resources/common.zip b/scripts/initializr/common/src/main/resources/common.zip index 783e9d78ed3..5e49e41eb1a 100644 Binary files a/scripts/initializr/common/src/main/resources/common.zip and b/scripts/initializr/common/src/main/resources/common.zip differ diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 2962219690f..e27a79a7d01 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -1,6 +1,6 @@ --- name: codename-one -description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven or Gradle, ParparVM/Android/iOS/JavaScript). Use when the project contains a `codenameone_settings.properties` (in `common/` for Maven, at the root for Gradle), a `settings.gradle.kts` applying the `com.codenameone` plugin, depends on `com.codenameone:codenameone-core`, edits CSS files under `src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build` or `./gradlew run`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` project (`cn1:backend`, `./gradlew runBackend`, `com.codename1.backend.*`, `@RestController`, `@Service`, `@Transactional`, `@Scheduled`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML. +description: Build and modify Codename One cross-platform mobile apps (Java 17, Maven or Gradle, ParparVM/Android/iOS/JavaScript). Use when the project contains a `codenameone_settings.properties` (in `common/` for Maven, at the root for Gradle), a `settings.gradle.kts` applying the `com.codenameone` plugin, depends on `com.codenameone:codenameone-core`, edits CSS files under `src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build` or `./gradlew run`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` project (`cn1:backend`, `./gradlew runBackend`, `com.codename1.backend.*`, `@RestController`, `@Component`, `@Transactional`, `@Scheduled`), or when the user asks to build a UI, write screen tests, generate screenshots, build a server or full-stack feature, or compare to Swing/HTML. metadata: type: skill --- @@ -43,7 +43,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/3d-graphics.md` — Portable GPU 3D (`com.codename1.gpu`): the `RenderView` + `Renderer` loop, declarative `Material` / `VertexFormat` (engine-generated shaders — no GLSL), `Primitives`, `GltfLoader` for glTF models, `Camera` / `Light` / `Matrix4`, and platform backends. Read this for product viewers, 3D scenes, or custom GPU rendering. - `references/snapshot-builds.md` — Edge case: compiling against a Codename One SNAPSHOT from git. - `references/debugging.md` — `jdb`-attach workflow for an agent: start the simulator paused, set breakpoints, dump locals, drive the session non-interactively from a script. -- `references/backend.md` — The **server side** (`backend/` module): Spring-style `@RestController`, `@Service`, `@Autowired`, `@Value`, scopes, `@Transactional`, `@Async`/`@Scheduled` and virtual vs platform threads, sessions, metrics and management endpoints, `@McpTool`, the development MCP tools (`backend_routes`, `backend_call`, `backend_sql`, ...), testing, and what each build error means. Everything is resolved at **build time** — read this before writing backend code, because a few Spring habits (runtime scanning, reflection, `org.springframework` imports) do not apply. +- `references/backend.md` — The **server side** (`backend/` module): Spring-style `@RestController`, `@Component`, `@Autowired`, `@Value`, scopes, `@Transactional`, `@Async`/`@Scheduled` and virtual vs platform threads, sessions, metrics and management endpoints, `@McpTool`, the development MCP tools (`backend_routes`, `backend_call`, `backend_sql`, ...), testing, and what each build error means. Everything is resolved at **build time** — read this before writing backend code, because a few Spring habits (runtime scanning, reflection, `org.springframework` imports) do not apply. - `references/full-stack-loop.md` — A change that spans the app **and** the backend: run the backend, connect its MCP and the simulator's, point the app at it, then drive the UI while checking the requests and rows the server saw. - `references/mcp-agent-control.md` — Driving the **running** simulator yourself over MCP: turn the server on from the simulator's `MCP` menu, register it with Claude Desktop / Claude Code / Codex in one click, then read the screen with `ui_snapshot`, find a field with `ui_find`, type with `ui_set_text` and tap with `ui_activate`. Read this when you need to know whether a flow *behaves* correctly, not just how it looks. - `tools/` — runnable Java 17 single-file utilities. `tools/IsApiSupported.java` answers "is this `java.*` class in the CN1 subset?"; `tools/IsCssValid.java` answers "does this `theme.css` compile?"; `tools/CompareToMockup.java` scores a rendered screenshot against a designer mockup (similarity %, with region masking); `tools/DesignImport.java` turns a Figma/Sketch/Adobe XD design — or an HTML/React design's `tokens.css`/`styles.css` (Claude-generated mockups) — into starter CN1 CSS + tokens + a layout map. **`tools/DumpForm.java`** boots the app in **desktop mode** and dumps a model of the current screen, which **`tools/DescribeForm.java`** (vision-free outline), **`tools/AlignmentCheck.java`** (designer alignment guides) and **`tools/GuiLint.java`** (nested scroll, opaque text/containers, image borders) analyse. **`tools/UpdateSkills.java`** self-updates this whole skill from GitHub. Run with `java tools/.java `. @@ -126,7 +126,7 @@ my-app/ ├── javascript/ # Optional: web port └── backend/ # Optional server side (see references/backend.md) ├── application.properties # Server settings: in the MODULE ROOT, not src/main/resources - └── src/main/java// # @RestController / @Service classes (Java 8 level, no UI classes) + └── src/main/java// # @RestController / @Component classes (Java 8 level, no UI classes) ``` **Only edit `common/`** for the app, and `backend/` for the server. The platform modules are optional thin wrappers: a project without them builds every platform from `common/`, and native interface implementations still go in `android/src/main/java`, `ios/src/main/objectivec` and so on, with or without a `pom.xml` beside them. Touching a platform module is almost always wrong unless you are intentionally writing a native interface. @@ -397,7 +397,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "Debug a faulty screen — attach `jdb` to the simulator" | `references/debugging.md` | | "It only breaks on the phone" / "attach a debugger to the Android/iOS build" / `android.onDeviceDebug`, `ios.onDeviceDebug` / drive the app on a device over MCP | `references/on-device-debugging.md` | | "Try the flow" / "fill in this form and press submit" / "drive the running app" / MCP | `references/mcp-agent-control.md` | -| "Add an endpoint / a service / a scheduled job" / `@RestController`, `@Service`, `@Transactional`, `@Scheduled`, `@Async` / "the server side" | `references/backend.md` | +| "Add an endpoint / a service / a scheduled job" / `@RestController`, `@Component`, `@Transactional`, `@Scheduled`, `@Async` / "the server side" | `references/backend.md` | | "Inspect or exercise the running backend" / `backend_*` MCP tools / "why did this request fail" | `references/backend.md` | | "Build this feature end to end" / "the screen should save to the server" / a bug that could be client or server | `references/full-stack-loop.md` | | Quick yes/no check: "is this `java.*` class supported", "does my `theme.css` compile" | `tools/` directory — `java tools/IsApiSupported.java ` / `java tools/IsCssValid.java ` | diff --git a/scripts/initializr/common/src/main/resources/skill/references/backend.md b/scripts/initializr/common/src/main/resources/skill/references/backend.md index 9d0edc8d747..67b5d8377b4 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/backend.md +++ b/scripts/initializr/common/src/main/resources/skill/references/backend.md @@ -1,7 +1,7 @@ # The Backend Module — Spring-Style Server Code The `backend/` module is the server side of the app, written in Java with Spring's -annotations under Codename One package names. `@RestController`, `@Service`, +annotations under Codename One package names. `@RestController`, `@Component`, `@Autowired`, `@Transactional`, `@Scheduled` and the rest mean what they mean in Spring. The difference is **when** they are resolved: at build time, into plain code. There is no container, no classpath scan, no proxy and no reflection when the @@ -43,12 +43,11 @@ Everything else in this file applies to both build tools. and `backend/application-.properties`, **not** `src/main/resources`. Environment variables override them (`cn1.datasource.url` is also read from `CN1_DATASOURCE_URL` and `DATABASE_URL`, `cn1.server.port` from `PORT`). -- **Common settings can be annotations too** (a single binary often ships with - no properties file): `@ServerConfig(port = 8080)`, `@SessionConfig(store = "db")`, - `@DataSourceConfig(url = "${DATABASE_URL}")`, `@StaticFilesConfig(root = "www")` - on any class. Each attribute is one `cn1.*` key compiled in UNDER the properties - files and environment, which still override it. A bad value or two classes - disagreeing is a build error. Never put a token or password in one. +- **`application.properties` is compiled into the server** as the bottom layer of + its configuration, so a single binary runs with no file beside it; a file or the + environment present at run time still overrides every key. Profile files are not + compiled in. Never put a token or password in it -- write `${NAME}` and set the + variable. - **Imports come from `com.codename1.backend.annotations`**, never `org.springframework.*`. @@ -60,14 +59,14 @@ package com.example.myapp; import com.codename1.backend.DataSource; import com.codename1.backend.annotations.*; -@Repository +@Component public class NoteStore { private final DataSource db; // built-in: the connection pool public NoteStore(DataSource db) { this.db = db; } // ... } -@Service +@Component public class Notes { private final NoteStore store; @Autowired private Mailer mailer; // private fields are fine @@ -92,14 +91,14 @@ public class NotesApi { | Annotation | Meaning | | --- | --- | -| `@Component`, `@Service`, `@Repository` | A bean. Name defaults to the simple class name, decapitalized. | +| `@Component` | A bean. Name defaults to the simple class name, decapitalized. | | `@Configuration` + `@Bean` | Factory methods; parameters are injected. Calling one `@Bean` method from another does NOT share the instance — take the other bean as a parameter. | | `@Autowired` | Constructor (needed only when there are several), field, or setter. `required = false` allowed. | | `@Qualifier("name")`, `@Primary` | Choose between several beans of one type. Two candidates and neither is enough → build error. | | `@Value("${key:fallback}")` | A configuration value, converted to String, a primitive or box, or an enum. | | `@ConfigurationProperties("mail")` | Calls the bean's setters from `mail.*` keys (`setMaxSize` reads `mail.maxSize` or `mail.max-size`). | | `@Scope("prototype")` | A new instance at each injection point. | -| `@RequestScope`, `@SessionScope` | One per HTTP request / session. Injected into a singleton through a generated subclass, so the class must not be final and needs a no-argument constructor. | +| `@Scope("request")`, `@Scope("session")` | One per HTTP request / session. Injected into a singleton through a generated subclass, so the class must not be final and needs a no-argument constructor. | | `@Lazy` | Built on first use (same subclass rule). The stand-in runs the no-argument constructor once at start-up, so put expensive set-up in `@PostConstruct`, which runs only on the real instance. | | `@Profile("dev")`, `@Profile("!prod")` | Only on that profile (`CN1_PROFILE`). | | `@ConditionalOnProperty("feature.x")`, `@ConditionalOnMissingBean` | Conditional beans. | @@ -133,7 +132,7 @@ shared by app and backend round-trips: ## Transactions ```java -@Service +@Component public class Transfers { private final DataSource db; public Transfers(DataSource db) { this.db = db; } @@ -165,7 +164,7 @@ public class Transfers { ## Background work: `@Async`, `@Scheduled`, threads ```java -@Service +@Component public class Reports { @Async // caller returns at once public Future build(String month) { @@ -232,8 +231,8 @@ Custom instruments: `Metrics.counter(...)`, `Metrics.histogram(...)`, - Management endpoints: `/manage/health`, `/manage/metrics`, `/manage/prometheus`, `/manage/jobs`, `/manage/managed`, and `POST /manage/managed/{bean}/{operation}`. Always present in the `cn1:backend` / `runBackend` dev run. A **packaged** binary contains them - only if the build asked -- `@EnableManagement` on a class or - `cn1.management.enabled=true` in a properties file -- otherwise the code is not + only if the build asked -- `cn1.management.enabled=true` in a properties + file -- otherwise the code is not in the binary at all. Outside dev they also need `cn1.management.token` (env). `cn1.management.enabled=false` turns built-in endpoints off at start-up. @@ -276,37 +275,74 @@ profile the token is optional, but a server without one listens on `127.0.0.1` only: its tools reach the database. To test from a phone or another machine, set `cn1.mcp.token` (the MCP client then sends it as a bearer token) or turn the endpoint off with `cn1.mcp.enabled=false`. A packaged binary contains the MCP -endpoint only when it has an `@McpTool` method, `@EnableMcpServer`, or -`cn1.mcp.enabled=true` in a properties file; the dev tools are only in the dev run. +endpoint only when it has an `@McpTool` method, or `cn1.mcp.enabled=true` +(or `cn1.mcp.path`/`cn1.mcp.allowedOrigins`) in a properties file; the dev tools are only in the dev run. ## Testing -- **Unit test** a service by constructing it: injection is constructor calls, so - `new Notes(new FakeStore())` is the whole setup. -- **Integration test** the wired server — what `@SpringBootTest` does — by - starting the generated wiring on a free port: +Tests are JUnit 5 in `backend/src/test/java`, written against Spring Boot's test +API under `com.codename1.backend.test`. The module's pom already has the +dependencies and the goals. ```java -Properties p = new Properties(); -p.setProperty("cn1.server.port", "0"); // or a free port you picked -Backend server = Backend.builder(Config.of(p, "test")) - .quiet() - .application(new BackendWiring()) // generated into target/classes - .start(); -try { - // HTTP calls against server.getServer().getPort() -} finally { - server.stop(); +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +@BackendTest // = @SpringBootTest; the build wires it +class NotesTest { + @Autowired MockMvc mvc; // in process, no socket + + @Test + void readsANote() throws Exception { + mvc.perform(get("/notes/{id}", 1)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("first")); + } } ``` -The `test` profile is a development profile: in-memory SQLite, tables created. +- **Unit test** a service by constructing it: injection is constructor calls, so + `new Notes(new FakeStore())` is the whole setup. +- **Over a real port**: `@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT)`, + then `@Autowired TestRestTemplate rest` (`getForObject`, `getForEntity`, + `postForEntity`, `exchange`, `withBasicAuth`) and `@LocalServerPort int port`. +- **Replace a bean**: a static nested `@TestConfiguration` class with a + `@Bean @Primary` method. Works on the JVM and compiled. +- **Mock a bean**: `@MockitoBean Store store;` with `org.mockito:mockito-core` + added as a test dependency. JVM only. +- **Settings**: `@BackendTest(properties = {"key=value"})`. These beat the + environment. The profile is `test` (a development profile): an in-memory + SQLite database, tables created. +- Test classes with the same configuration share one running server; do not + rely on test order. + +Run: + +```bash +mvn -pl backend -Dcodename1.platform=backend test +mvn -pl backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # also as a native binary +./gradlew :backend:test # Gradle (./gradlew test in a backend-only project) +./gradlew :backend:backendTest # Gradle, as a native binary +``` + +The compiled run translates the same tests into a native test binary (needs +clang, as `cn1:backend-package` does; skipped on Windows). Classes that import +Mockito are left out of it. Use only the JUnit 5 API the shim provides: +lifecycle annotations, `@Disabled`, `@DisplayName`, `@Tag`, `Assertions` +(including `assertThrows` with a lambda) and `Assumptions`. Results: +`backend/target/surefire-reports/TEST-*.xml` and `TEST-*-compiled.xml`. ## Build errors you will see, and what they mean | Message fragment | Fix | | --- | --- | -| `needs a X, and no bean has that type` | Annotate the implementation `@Service`/`@Component`, or add a `@Bean` method. | +| `needs a X, and no bean has that type` | Annotate the implementation `@Component`, or add a `@Bean` method. | | `could receive any of ...` | Mark one `@Primary` or inject with `@Qualifier("name")`. | | `The constructors form a cycle` | Inject one side through an `@Autowired` field or setter. | | `is final, so it can only be set by the constructor` | Take it as a constructor parameter. | diff --git a/scripts/ios/screenshots-tv/README.md b/scripts/ios/screenshots-tv/README.md index 35d8998c93d..bad39bd4fda 100644 --- a/scripts/ios/screenshots-tv/README.md +++ b/scripts/ios/screenshots-tv/README.md @@ -2,7 +2,7 @@ Reference PNGs for the `build-ios-tv` CI job (`scripts/run-tv-ui-tests.sh`), captured from the `hellocodenameone` `cn1ss` suite running on the tvOS simulator -(1920x1080) and streamed through `Cn1ssScreenshotServer`. +(1920x1080) and streamed to the test server in `scripts/hellocodenameone/backend`. This set is seeded once the tvOS native slice compiles end-to-end (see `Ports/iOSPort/nativeSources/TVOS_PORT.md`), the same way the watch goldens in diff --git a/scripts/lib/cn1ss.sh b/scripts/lib/cn1ss.sh index 07fe95252f6..ffd41ae9c42 100644 --- a/scripts/lib/cn1ss.sh +++ b/scripts/lib/cn1ss.sh @@ -149,26 +149,27 @@ cn1ss_java_run() { # --------------------------------------------------------------------------- -# The websocket screenshot server +# The test server: screenshots in, REST out # --------------------------------------------------------------------------- # -# Two implementations, selected by CN1SS_WS_SERVER: +# scripts/hellocodenameone/backend, a Codename One backend. It receives the +# screenshots the suite streams over a websocket, and serves the REST surface the +# app's networking tests (tests/backend/*) drive -- on the same fixed port, 8765, +# which the device side hardcodes: 10.0.2.2 from the Android emulator, 127.0.0.1 +# everywhere else. # -# javase (default) the Codename One backend server -- vm/backend/src plus -# impl/javase plus demo/cn1ss plus the core classes it shares -# (vm/backend/shared-sources.sh) -- on this leg's JDK. Same Java the -# translated binary runs; no C toolchain, nothing new to install. -# native the translated binary from vm/backend/build.sh. This is the arm that -# actually ships, and exactly one CI leg runs it. -# legacy scripts/common/java/Cn1ssScreenshotServer.java, the hand-rolled -# server this replaced. Kept because native Windows has no backend arm -# (CleanTargetIntegrationTest and scripts/windows/run-hello.bat both -# compile and run it directly), and useful as an escape hatch while the -# swap settles. +# CN1SS_WS_SERVER picks how it runs: # -# Whichever is chosen, it is proved to work with --selfcheck before the leg spends -# forty minutes finding out otherwise: a readiness line only says a port was -# bound, not that the handshake works or that the output directory is writable. +# javase (default) the JVM build, on this leg's JDK. +# native translated by ParparVM and run as a binary, the way a backend ships. +# Needs clang and the OpenSSL, libcurl and nghttp2 headers, so only a +# leg that installs them asks for it. +# +# scripts/hellocodenameone/backend/server.sh does the building; it installs the +# backend runtime from this checkout first, because a CI cache can restore an +# older 8.0-SNAPSHOT. The server's own tests -- its websocket self-check among +# them -- run in its module build; here a handshake probe proves the process that +# was started answers before the leg spends forty minutes finding out otherwise. cn1ss_repo_root() { local script_dir @@ -176,241 +177,113 @@ cn1ss_repo_root() { echo "$script_dir" } -# Compiles vm/backend (shared runtime + the Java SE layer + the cn1ss demo) into a -# content-addressed cache, so ten legs on one runner pay for it once. -# -# Deliberately NOT vm/backend/run-javase.sh: that script runs generate-contract.sh -# unconditionally, which needs codenameone-core and the CN1 Maven plugin in a local -# repository that most UI-test legs never build -- and it compiles into a mktemp -# directory it deletes on exit, so every leg would pay the full javac every run. -cn1ss_backend_setup() { - local root cache digest sources - root="$(cn1ss_repo_root)" - if [ ! -d "$root/vm/backend/src" ]; then - cn1ss_log "cn1ss_backend_setup: vm/backend is missing at $root" - return 1 - fi - if [ -z "${CN1SS_JAVA_BIN:-}" ]; then - cn1ss_log "cn1ss_backend_setup: cn1ss_setup must run first" - return 1 - fi - if [ -z "${CN1SS_JAVAC_BIN:-}" ]; then - cn1ss_log "cn1ss_backend_setup: no javac located" - return 1 - fi +cn1ss_server_script() { + echo "$(cn1ss_repo_root)/scripts/hellocodenameone/backend/server.sh" +} - # The shared core classes are part of the compile AND of the digest: they live - # in CodenameOne/src, so a digest over vm/backend alone would reuse a stale - # cache after an ORM change. - local shared - if ! shared="$("$root/vm/backend/shared-sources.sh")"; then - cn1ss_log "cn1ss_backend_setup: could not list the shared core sources" - return 1 - fi - sources="$(mktemp)" - { find "$root/vm/backend/src" "$root/vm/backend/impl/javase" "$root/vm/backend/demo/cn1ss" \ - -name '*.java' -print; printf '%s\n' "$shared"; } | sort > "$sources" - # CONTENT, not mtime: a branch switch can leave sources older than a stamp. - digest="$(xargs shasum < "$sources" 2>/dev/null | shasum | awk '{print $1}')" - local tmp_root="${TMPDIR:-/tmp}" - tmp_root="${tmp_root%/}" - cache="${CN1SS_BACKEND_CACHE_DIR:-$tmp_root/cn1ss-backend-cache}/$digest" +cn1ss_server_mode() { + case "${CN1SS_WS_SERVER:-javase}" in + javase) echo "--jvm" ;; + native) echo "--native" ;; + *) + cn1ss_log "unknown CN1SS_WS_SERVER '${CN1SS_WS_SERVER}' (javase|native)" + return 1 + ;; + esac +} - if [ -d "$cache/classes" ] && [ -f "$cache/.ok" ]; then - CN1SS_BACKEND_CLASSPATH="$cache/classes" - rm -f "$sources" - cn1ss_log "Reusing the compiled backend server in $CN1SS_BACKEND_CLASSPATH" +# Builds the server once per leg, before anything waits on it. +cn1ss_ws_prepare() { + local mode + mode="$(cn1ss_server_mode)" || return 1 + if [ "${CN1SS_WS_PREPARED:-}" = "$mode" ]; then return 0 fi - - # Into a sibling and then moved, so two legs starting at once cannot read a - # half-written class directory. The digest is in the path, so the move is safe. - local staging - staging="$(mktemp -d "${cache%/*}/staging.XXXXXX" 2>/dev/null)" || { - mkdir -p "${cache%/*}" - staging="$(mktemp -d "${cache%/*}/staging.XXXXXX")" - } - cn1ss_log "Compiling the backend websocket server -> $cache/classes" - if ! "$CN1SS_JAVAC_BIN" -nowarn -encoding UTF-8 -d "$staging/classes" @"$sources" 2>&1 \ - | sed 's/^/[cn1ss-backend] /'; then - cn1ss_log "cn1ss_backend_setup: javac failed" - rm -rf "$staging" "$sources" + cn1ss_log "Building the test server ($mode)" + local build_log rc=0 + build_log="$(mktemp)" + "$(cn1ss_server_script)" build "$mode" >"$build_log" 2>&1 || rc=$? + sed 's/^/[cn1ss-backend-build] /' "$build_log" >&2 + rm -f "$build_log" + if [ "$rc" != "0" ]; then + cn1ss_log "cn1ss_ws_prepare: the test server did not build (exit $rc)" return 1 fi - rm -f "$sources" - if [ ! -d "$staging/classes" ]; then - cn1ss_log "cn1ss_backend_setup: javac produced no classes" - rm -rf "$staging" - return 1 - fi - touch "$staging/.ok" - rm -rf "$cache" - mkdir -p "${cache%/*}" - mv "$staging" "$cache" 2>/dev/null || { rm -rf "$staging"; [ -d "$cache/classes" ] || return 1; } - CN1SS_BACKEND_CLASSPATH="$cache/classes" + CN1SS_WS_PREPARED="$mode" return 0 } -# Builds the translated binary. One leg only; cached on the source digest. -cn1ss_backend_build_native() { - local root out digest - root="$(cn1ss_repo_root)" - # THE TRANSLATOR AND THE BOOT CLASSPATH TOO. build.sh consumes both, so a - # digest over vm/backend alone is unchanged when ByteCodeTranslator or JavaAPI - # moves -- and this function then reuses a cached executable and never calls - # build.sh at all. The one leg meant to exercise the translated server would be - # testing a stale translator against new sources, which is exactly the - # regression it exists to catch. - # And the core classes build.sh compiles from CodenameOne/src, for the same - # reason. - digest="$({ find "$root/vm/backend/src" "$root/vm/backend/impl/parparvm" \ - "$root/vm/backend/native" "$root/vm/backend/demo/cn1ss" \ - "$root/vm/backend/build.sh" "$root/vm/backend/shared-sources.sh" \ - "$root/vm/ByteCodeTranslator/src" "$root/vm/JavaAPI/src" \ - -type f -print 2>/dev/null; "$root/vm/backend/shared-sources.sh" 2>/dev/null; } \ - | tr '\n' '\0' | xargs -0 shasum 2>/dev/null | shasum | awk '{print $1}')" - local tmp_root="${TMPDIR:-/tmp}" - tmp_root="${tmp_root%/}" - out="${CN1SS_NATIVE_CACHE_DIR:-$tmp_root/cn1ss-native-cache}/$digest/cn1ss-screenshot-server" - if [ -x "$out" ]; then - CN1SS_WS_SERVER_BINARY="$out" - cn1ss_log "Reusing the native screenshot server at $out" +# The RFC 6455 handshake against the running server, with the standard's own key, +# so the accept value checked is the one RFC 6455 prints: a 101 with a wrong value +# is a handshake every conforming client rejects, and a missing SHA-1 binding on +# the translated arm looks exactly like that. +cn1ss_ws_selfcheck() { + local port="$1" answer + if [ "${CN1SS_WS_SELFCHECK:-1}" != "1" ]; then return 0 fi - if [ -z "${JDK_8_HOME:-}" ]; then - cn1ss_log "cn1ss_backend_build_native: JDK_8_HOME is not set, which build.sh requires by name" - return 1 + if ! command -v curl >/dev/null 2>&1; then + cn1ss_log "curl is not available; skipping the handshake probe" + return 0 fi - mkdir -p "$(dirname "$out")" - cn1ss_log "Translating the screenshot server to a native binary (this is the arm that ships)" - # NOTE: CN1_BACKEND_HTTPS=0 would look like a free way to avoid the TLS - # dependencies, and it is a trap -- build.sh DELETES cn1_backend_crypto.c in - # that mode, and a native with no C symbol takes its Java method with it, so - # Crypto.sha1 would vanish and every handshake would compute the wrong accept. - # Install libssl/libcurl/libnghttp2 instead; the vm-tests job already does. - # CN1_BACKEND_STANDALONE_DEMO because this demo needs neither the @RestClient - # contract nor demo/common, and generating the contract needs codenameone-core - # in the repo-local .m2-repo -- which the legs that run this server do not - # build. Without it build.sh stops with "codenameone-core:8.0-SNAPSHOT is not in - # .m2-repo" and produces no binary. - ( cd "$root/vm/backend" \ - && CN1_BACKEND_DEMO=demo/cn1ss CN1_BACKEND_SQLITE=0 CN1_BACKEND_STANDALONE_DEMO=1 \ - ./build.sh Cn1ssScreenshotServer com.demo "$out" ) 2>&1 \ - | sed 's/^/[cn1ss-native] /' - if [ ! -x "$out" ]; then - cn1ss_log "cn1ss_backend_build_native: build.sh produced no binary" + answer="$(curl -s --http1.1 -i -N --max-time 3 \ + -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ + "http://127.0.0.1:$port/cn1ss" 2>/dev/null || true)" + if ! printf '%s' "$answer" | grep -q "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; then + cn1ss_log "Test server handshake probe FAILED; it answered:" + printf '%s\n' "$answer" | head -n 10 | sed 's/^/[cn1ss-selfcheck] /' >&2 return 1 fi - CN1SS_WS_SERVER_BINARY="$out" - return 0 -} - -# Proves the chosen server really serves websockets, in about a second. -cn1ss_ws_selfcheck() { - local probe_dir rc - probe_dir="$(mktemp -d)" - set -- "${CN1SS_WS_COMMAND[@]}" --port 0 --out "$probe_dir" --selfcheck - if "$@" >"$probe_dir/selfcheck.log" 2>&1; then - rc=0 - else - rc=$? - fi - if [ "$rc" -ne 0 ]; then - cn1ss_log "Screenshot server self-check FAILED (exit $rc):" - sed 's/^/[cn1ss-selfcheck] /' "$probe_dir/selfcheck.log" >&2 - fi - rm -rf "$probe_dir" - return $rc -} - -# Fills CN1SS_WS_COMMAND for the selected arm. -cn1ss_ws_prepare() { - local arm="${CN1SS_WS_SERVER:-javase}" - CN1SS_WS_COMMAND=() - case "$arm" in - legacy) - if [ -z "${CN1SS_JAVA_CLASSPATH:-}" ]; then - cn1ss_log "cn1ss_ws_prepare: cn1ss_setup must run first" - return 1 - fi - CN1SS_WS_COMMAND=("$CN1SS_JAVA_BIN" "${CN1SS_JAVA_OPTS[@]}" -cp "$CN1SS_JAVA_CLASSPATH" \ - Cn1ssScreenshotServer) - ;; - javase) - cn1ss_backend_setup || return 1 - CN1SS_WS_COMMAND=("$CN1SS_JAVA_BIN" "${CN1SS_JAVA_OPTS[@]}" -cp "$CN1SS_BACKEND_CLASSPATH" \ - com.demo.Cn1ssScreenshotServer) - ;; - native) - if [ -z "${CN1SS_WS_SERVER_BINARY:-}" ] || [ ! -x "${CN1SS_WS_SERVER_BINARY:-}" ]; then - cn1ss_backend_build_native || return 1 - fi - CN1SS_WS_COMMAND=("$CN1SS_WS_SERVER_BINARY") - ;; - *) - cn1ss_log "cn1ss_ws_prepare: unknown CN1SS_WS_SERVER '$arm' (javase|native|legacy)" - return 1 - ;; - esac - cn1ss_log "Screenshot server arm: $arm" - # The legacy server has no --selfcheck; it is the implementation being replaced. - if [ "$arm" != "legacy" ] && [ "${CN1SS_WS_SELFCHECK:-1}" = "1" ]; then - cn1ss_ws_selfcheck || return 1 + if ! curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" >/dev/null 2>&1; then + cn1ss_log "Test server health probe FAILED on /api/health" + return 1 fi return 0 } -# WebSocket screenshot server bootstrap. Starts Cn1ssScreenshotServer on -# an ephemeral port; captures the bound port from its first stdout line so -# the runner can hand it to the device via -Dcn1ss.websocket.url=... -# Sets CN1SS_WS_PORT and CN1SS_WS_PID on success. +# Starts the test server on the fixed port (CN1SS_WS_BIND_PORT overrides it), +# writing screenshots into the given directory. Sets CN1SS_WS_PORT, CN1SS_WS_PID +# and CN1SS_WS_LOG on success. cn1ss_start_ws_server() { - local out_dir="$1" + local out_dir="$1" mode if [ -z "$out_dir" ]; then cn1ss_log "cn1ss_start_ws_server: missing output dir" return 1 fi mkdir -p "$out_dir" 2>/dev/null || true - # The native arm needs no JDK of its own, but every leg that starts this server - # has already run cn1ss_setup for the report helpers, so requiring it here keeps - # one failure mode instead of two. - if [ "${CN1SS_WS_SERVER:-javase}" != "native" ] \ - && { [ -z "${CN1SS_JAVA_BIN:-}" ] || [ -z "${CN1SS_JAVA_CLASSPATH:-}" ]; }; then - cn1ss_log "cn1ss_start_ws_server: cn1ss_setup must be called first" - return 1 - fi - local port_file - port_file="$(mktemp)" - # Bind the fixed standard port (override with CN1SS_WS_BIND_PORT). The device - # runner defaults to ws://HOST:8765 with no per-run injection, so this must - # match CN1SS_WS_DEFAULT_PORT in Cn1ssDeviceRunnerHelper.java. CN1SS_WS_PORT - # (set below from the server's reported port) stays the captured bound port. - local bind_port="${CN1SS_WS_BIND_PORT:-8765}" + mode="$(cn1ss_server_mode)" || return 1 if ! cn1ss_ws_prepare; then - cn1ss_log "cn1ss_start_ws_server: could not prepare the screenshot server" - rm -f "$port_file" + cn1ss_log "cn1ss_start_ws_server: could not prepare the test server" return 1 fi - "${CN1SS_WS_COMMAND[@]}" --port "$bind_port" --out "$out_dir" \ - >"$port_file" 2>&1 & + local log_file bind_port="${CN1SS_WS_BIND_PORT:-8765}" + log_file="$(mktemp)" + "$(cn1ss_server_script)" run "$mode" --prebuilt --port "$bind_port" --out "$out_dir" \ + >"$log_file" 2>&1 & CN1SS_WS_PID=$! - # Wait for the server to print "CN1SS_SERVER_PORT=" on the first line. - local attempt - for attempt in 1 2 3 4 5 6 7 8 9 10; do - if grep -q "^CN1SS_SERVER_PORT=" "$port_file" 2>/dev/null; then - CN1SS_WS_PORT="$(grep -m1 "^CN1SS_SERVER_PORT=" "$port_file" | cut -d'=' -f2)" - CN1SS_WS_LOG="$port_file" - cn1ss_log "Cn1ssScreenshotServer listening on port $CN1SS_WS_PORT (pid $CN1SS_WS_PID, log $port_file)" + local attempt line + for attempt in $(seq 1 300); do + line="$(grep -m1 -o "listening on https\{0,1\} port [0-9]*" "$log_file" 2>/dev/null || true)" + if [ -n "$line" ]; then + CN1SS_WS_PORT="${line##* }" + CN1SS_WS_LOG="$log_file" + cn1ss_log "Test server listening on port $CN1SS_WS_PORT (pid $CN1SS_WS_PID, log $log_file)" + if ! cn1ss_ws_selfcheck "$CN1SS_WS_PORT"; then + cn1ss_stop_ws_server + return 1 + fi return 0 fi if ! kill -0 "$CN1SS_WS_PID" 2>/dev/null; then - cn1ss_log "Cn1ssScreenshotServer died before reporting a port:" - cat "$port_file" >&2 + cn1ss_log "The test server exited before it listened:" + cat "$log_file" >&2 return 1 fi sleep 0.2 done - cn1ss_log "Timed out waiting for Cn1ssScreenshotServer to bind a port" + cn1ss_log "Timed out waiting for the test server to listen" + cat "$log_file" >&2 kill "$CN1SS_WS_PID" 2>/dev/null || true return 1 } @@ -419,28 +292,30 @@ cn1ss_stop_ws_server() { if [ -n "${CN1SS_WS_PID:-}" ]; then kill "$CN1SS_WS_PID" 2>/dev/null || true wait "$CN1SS_WS_PID" 2>/dev/null || true - cn1ss_log "Cn1ssScreenshotServer (pid $CN1SS_WS_PID) stopped" + cn1ss_log "Test server (pid $CN1SS_WS_PID) stopped" unset CN1SS_WS_PID CN1SS_WS_PORT fi # Persist the server log so the WebSocket transport is debuggable from CI # artifacts. The server prints one CN1SS:INFO:test=... line per delivered - # screenshot plus any "binary frame without META" / hash_mismatch warnings; + # screenshot plus any "binary frame with no META" / hash_mismatch warnings; # without this the only copy lived in a mktemp file that never reached the # uploaded artifacts (the WS pipeline was effectively a black box on # failure). Also surface a one-line summary + tail in the job log directly. if [ -n "${CN1SS_WS_LOG:-}" ] && [ -s "${CN1SS_WS_LOG:-}" ]; then local delivered dropped - delivered="$(grep -c "^CN1SS:INFO:test=" "$CN1SS_WS_LOG" 2>/dev/null || echo 0)" - dropped="$(grep -c "binary frame without META" "$CN1SS_WS_LOG" 2>/dev/null || echo 0)" - cn1ss_log "WebSocket server summary: ${delivered} screenshot(s) written, ${dropped} unpaired binary frame(s) dropped" + # grep -c prints the count and exits 1 when it is 0, so "|| echo 0" would + # append a second 0 rather than stand in for a missing one. + delivered="$(grep -c "^CN1SS:INFO:test=" "$CN1SS_WS_LOG" 2>/dev/null)" || true + dropped="$(grep -c "binary frame with no META" "$CN1SS_WS_LOG" 2>/dev/null)" || true + cn1ss_log "Test server summary: ${delivered:-0} screenshot(s) written, ${dropped:-0} unpaired binary frame(s) dropped" if [ -n "${ARTIFACTS_DIR:-}" ]; then mkdir -p "$ARTIFACTS_DIR" 2>/dev/null || true cp -f "$CN1SS_WS_LOG" "$ARTIFACTS_DIR/cn1ss-ws-server.log" 2>/dev/null \ && cn1ss_log "WebSocket server log saved to $ARTIFACTS_DIR/cn1ss-ws-server.log" fi - cn1ss_log "----- last 40 lines of Cn1ssScreenshotServer log -----" + cn1ss_log "----- last 40 lines of the test server log -----" tail -n 40 "$CN1SS_WS_LOG" 2>/dev/null | sed 's/^/[cn1ss-ws-server] /' - cn1ss_log "----- end of Cn1ssScreenshotServer log -----" + cn1ss_log "----- end of the test server log -----" fi unset CN1SS_WS_LOG } diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index 7a7d3020784..e942d7cef46 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -44,8 +44,8 @@ cd "$REPO_ROOT" CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" source "$SCRIPT_DIR/lib/cn1ss.sh" -if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" ]; then - ra_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" >&2 +if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" ]; then + ra_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" >&2 exit 3 fi cn1ss_log() { ra_log "$1"; } diff --git a/scripts/run-ios-ui-tests.sh b/scripts/run-ios-ui-tests.sh index c71c7e71771..2646c818490 100755 --- a/scripts/run-ios-ui-tests.sh +++ b/scripts/run-ios-ui-tests.sh @@ -68,8 +68,8 @@ cd "$REPO_ROOT" CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" source "$SCRIPT_DIR/lib/cn1ss.sh" -if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" ]; then - ri_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" >&2 +if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" ]; then + ri_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" >&2 exit 3 fi cn1ss_log() { ri_log "$1"; } diff --git a/scripts/run-javascript-screenshot-tests.sh b/scripts/run-javascript-screenshot-tests.sh index c4d239f0a6b..348792acf44 100755 --- a/scripts/run-javascript-screenshot-tests.sh +++ b/scripts/run-javascript-screenshot-tests.sh @@ -36,8 +36,8 @@ cd "$REPO_ROOT" CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" source "$SCRIPT_DIR/lib/cn1ss.sh" -if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" ]; then - rj_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" >&2 +if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" ]; then + rj_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" >&2 exit 3 fi cn1ss_log() { rj_log "$1"; } diff --git a/scripts/run-javase-device-tests.sh b/scripts/run-javase-device-tests.sh index 9136626217b..b0fc435321b 100755 --- a/scripts/run-javase-device-tests.sh +++ b/scripts/run-javase-device-tests.sh @@ -14,7 +14,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" -if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" ]; then +if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" ]; then jd_log "CN1SS helper sources not found at $CN1SS_HELPER_SOURCE_DIR" >&2 exit 2 fi diff --git a/scripts/run-mac-catalyst-ui-tests.sh b/scripts/run-mac-catalyst-ui-tests.sh index 78145550ea5..5c74a64884e 100755 --- a/scripts/run-mac-catalyst-ui-tests.sh +++ b/scripts/run-mac-catalyst-ui-tests.sh @@ -68,8 +68,8 @@ CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/cn1ss.sh" -if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" ]; then - rm_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" >&2 +if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" ]; then + rm_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" >&2 exit 3 fi cn1ss_log() { rm_log "$1"; } diff --git a/scripts/run-macos-ui-tests.sh b/scripts/run-macos-ui-tests.sh index f813eb1967f..19677ea6976 100755 --- a/scripts/run-macos-ui-tests.sh +++ b/scripts/run-macos-ui-tests.sh @@ -68,8 +68,8 @@ CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/cn1ss.sh" -if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" ]; then - rm_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/Cn1ssScreenshotServer.java" >&2 +if [ ! -f "$CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" ]; then + rm_log "Missing CN1SS helper: $CN1SS_HELPER_SOURCE_DIR/ProcessScreenshots.java" >&2 exit 3 fi cn1ss_log() { rm_log "$1"; } diff --git a/scripts/run-tv-ui-tests.sh b/scripts/run-tv-ui-tests.sh index 1476b4acfe4..7b7a395e589 100755 --- a/scripts/run-tv-ui-tests.sh +++ b/scripts/run-tv-ui-tests.sh @@ -8,7 +8,7 @@ # Apple TV reuses the iOS UIApplicationMain entry and the Metal renderer, so # the
TV target is built like a # regular iOS app for the appletvsimulator SDK and launched via simctl. It -# streams each screenshot to the host-side Cn1ssScreenshotServer over +# streams each screenshot to the host-side test server (scripts/hellocodenameone/backend) over # ws://127.0.0.1:8765 -- the same transport the iOS / watch jobs use -- so the # comparison/report tooling in scripts/lib/cn1ss.sh is reused verbatim. # @@ -140,7 +140,7 @@ mkdir -p "$WS_RAW_DIR" "$PREVIEW_DIR" cleanup() { cn1ss_stop_ws_server 2>/dev/null || true; xcrun simctl terminate "$TV_UDID" "$BUNDLE_ID" 2>/dev/null || true; } trap cleanup EXIT -cn1ss_start_ws_server "$WS_RAW_DIR" || { rt_log "Failed to start Cn1ssScreenshotServer"; exit 6; } +cn1ss_start_ws_server "$WS_RAW_DIR" || { rt_log "Failed to start the test server"; exit 6; } rt_log "WS sink on port ${CN1SS_WS_PORT:-8765} -> $WS_RAW_DIR" xcrun simctl terminate "$TV_UDID" "$BUNDLE_ID" 2>/dev/null || true diff --git a/scripts/run-watch-ui-tests.sh b/scripts/run-watch-ui-tests.sh index e1708e08f6f..32d57fd3a4b 100755 --- a/scripts/run-watch-ui-tests.sh +++ b/scripts/run-watch-ui-tests.sh @@ -4,7 +4,7 @@ # # The watch app is SwiftUI-rooted and renders Codename One through the Core # Graphics backend (no GL/Metal on watchOS). It streams each screenshot to the -# host-side Cn1ssScreenshotServer over ws://127.0.0.1:8765 -- the same transport +# host-side test server (scripts/hellocodenameone/backend) over ws://127.0.0.1:8765 -- the same transport # the iOS jobs use -- so the comparison/report tooling in scripts/lib/cn1ss.sh # is reused verbatim; only the build (watch target) and the simulator (a watch # device booted directly via simctl, since watchOS apps aren't launched through @@ -159,7 +159,7 @@ APP_CONSOLE_PID="" cleanup() { cn1ss_stop_ws_server 2>/dev/null || true; [ -n "$APP_CONSOLE_PID" ] && kill "$APP_CONSOLE_PID" 2>/dev/null || true; xcrun simctl terminate "$WATCH_UDID" "$BUNDLE_ID" 2>/dev/null || true; } trap cleanup EXIT -cn1ss_start_ws_server "$WS_RAW_DIR" || { rw_log "Failed to start Cn1ssScreenshotServer"; exit 6; } +cn1ss_start_ws_server "$WS_RAW_DIR" || { rw_log "Failed to start the test server"; exit 6; } rw_log "WS sink on port ${CN1SS_WS_PORT:-8765} -> $WS_RAW_DIR" xcrun simctl terminate "$WATCH_UDID" "$BUNDLE_ID" 2>/dev/null || true diff --git a/scripts/website/build.sh b/scripts/website/build.sh index 10fcf5700cd..1a50dceac37 100755 --- a/scripts/website/build.sh +++ b/scripts/website/build.sh @@ -273,7 +273,7 @@ build_javadocs_for_site() { --url-root /backend/javadoc/ \ --counterpart-root /javadoc/ \ --shared-sources "${REPO_ROOT}/Ports/CLDC11/src" \ - -sourcepath "${backend_sources}:${REPO_ROOT}/Ports/CLDC11/src" \ + -sourcepath "${backend_sources}:${REPO_ROOT}/Ports/CLDC11/src:${REPO_ROOT}/vm/backend/test/junit-shim" \ -quiet \ -protected \ "@${backend_argfile}" @@ -286,6 +286,7 @@ build_javadocs_for_site() { echo "Hugo backend API generation emitted com.codename1.impl; the internal package must stay excluded." >&2 exit 1 fi + "${REPO_ROOT}/scripts/check-backend-javadoc-internals.sh" "${backend_content_dir}" md # Every page of this reference must say it is the backend's. A page that came # out labelled as the client API is the confusion this split exists to stop, # and it would look entirely normal on the site. diff --git a/scripts/windows/build-screenshot.bat b/scripts/windows/build-screenshot.bat index 8e00979482c..c710343ed73 100644 --- a/scripts/windows/build-screenshot.bat +++ b/scripts/windows/build-screenshot.bat @@ -2,7 +2,7 @@ rem Build + run the native Windows port's WebSocket screenshot-capture test rem (CleanTargetIntegrationTest#capturesFormScreenshotOverWebSocket): renders the rem Contacts UI offscreen, PNG-encodes it, streams it over the cn1ss WebSocket to -rem a local Cn1ssScreenshotServer, and writes cn1-windows-native.png. The PNG is +rem the local test server (scripts\hellocodenameone\backend), and writes cn1-windows-native.png. The PNG is rem placed in CN1_SHOT_OUTPUT_DIR (default: the repo's developer-guide-windows-port). setlocal call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=arm64 -no_logo || exit /b 1 diff --git a/scripts/windows/build-wsorch.bat b/scripts/windows/build-wsorch.bat index 0a5c1014be4..64ddae70df9 100644 --- a/scripts/windows/build-wsorch.bat +++ b/scripts/windows/build-wsorch.bat @@ -1,6 +1,6 @@ @echo off rem Build + run the end-to-end websocket cn1ss capture test: starts a -rem Cn1ssScreenshotServer and runs the native app that streams its Form +rem the cn1ss test server (scripts\hellocodenameone\backend) and runs the native app that streams its Form rem screenshot over com.codename1.io WebSocket to the server. setlocal call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=arm64 -no_logo || exit /b 1 diff --git a/scripts/windows/run-hello.bat b/scripts/windows/run-hello.bat index 98278dfa6f6..fbaeedee647 100644 --- a/scripts/windows/run-hello.bat +++ b/scripts/windows/run-hello.bat @@ -1,5 +1,5 @@ @echo off -rem Run the native Windows hellocodenameone exe against a Cn1ssScreenshotServer +rem Run the native Windows hellocodenameone exe against the CI test server rem and let the Cn1ssDeviceRunner suite emit screenshots over the WebSocket. rem Screenshots land in OUT (default: repo developer-guide-windows-port/hello-shots). rem Arg 1 (optional): seconds to let the suite run (default 180). @@ -11,21 +11,17 @@ if not exist "%OUT%" mkdir "%OUT%" del /q "%OUT%\*.png" 2>nul del /q "%TEMP%\hello-out.txt" 2>nul -rem Compile the shared cn1ss screenshot server. -set SRV=%TEMP%\cn1ss-srv -if not exist "%SRV%" mkdir "%SRV%" -"%JAVA_HOME%\bin\javac" -d "%SRV%" -sourcepath Y:\scripts\common\java Y:\scripts\common\java\Cn1ssScreenshotServer.java || exit /b 1 - -rem Start the server (background, log captured) and wait until it binds -rem 127.0.0.1:8765 (it prints a CN1SS_SERVER_PORT readiness line). +rem Start the test server (scripts\hellocodenameone\backend: screenshots in, REST +rem out) through Git for Windows' bash, which builds it with Maven the first time, +rem and wait until it prints its "listening on http port" readiness line. del /q "%TEMP%\cn1ss-server.log" 2>nul -start "cn1ss-server" /b cmd /c ""%JAVA_HOME%\bin\java" -cp "%SRV%" Cn1ssScreenshotServer --port 8765 --out "%OUT%" > %TEMP%\cn1ss-server.log 2>&1" +start "cn1ss-server" /b cmd /c ""%ProgramFiles%\Git\bin\bash.exe" Y:/scripts/hellocodenameone/backend/server.sh run --jvm --port 8765 --out "%OUT%" > %TEMP%\cn1ss-server.log 2>&1" set /a tries=0 :waitserver ping -n 2 127.0.0.1 >nul -findstr /C:"CN1SS_SERVER_PORT" "%TEMP%\cn1ss-server.log" >nul 2>&1 && goto serverup +findstr /C:"listening on http port" "%TEMP%\cn1ss-server.log" >nul 2>&1 && goto serverup set /a tries+=1 -if %tries% LSS 15 goto waitserver +if %tries% LSS 300 goto waitserver echo WARNING: server readiness line not seen; proceeding anyway :serverup echo ====SERVER LOG (startup)==== diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java index dae0db383c5..d67f626dad4 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/JavascriptMethodGenerator.java @@ -2571,13 +2571,19 @@ private static String stripDeadCaseLabels(String body) { liveTargets.add("0"); // Also scan ``__cn1TryCatch`` table from the prefix for // {s:N,e:M,h:K,...} so handler / range pcs aren't dropped. + // The handler is followed by ",t:" for a typed catch and by "}" for a + // catch-all -- a finally, or a synchronized block's release. Matching + // only the "," form stripped every catch-all handler's case label, so an + // exception unwinding through a finally was dispatched to a pc with no + // case, fell to "default: return" and vanished: the method returned + // normally and nothing above it saw the throw. java.util.regex.Matcher tryRangesPrefix = java.util.regex.Pattern.compile( - "\\{s:(\\d+),e:(\\d+),h:(\\d+),").matcher(prefix); + "\\{s:(\\d+),e:(\\d+),h:(\\d+)[,}]").matcher(prefix); while (tryRangesPrefix.find()) { liveTargets.add(tryRangesPrefix.group(3)); } java.util.regex.Matcher tryRanges = java.util.regex.Pattern.compile( - "\\{s:(\\d+),e:(\\d+),h:(\\d+),").matcher(region); + "\\{s:(\\d+),e:(\\d+),h:(\\d+)[,}]").matcher(region); while (tryRanges.find()) { liveTargets.add(tryRanges.group(3)); } diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LabelInstruction.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LabelInstruction.java index 3e51f3c59de..80e2c44c20a 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LabelInstruction.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/LabelInstruction.java @@ -46,6 +46,16 @@ static class Pair { private static Map> tryBeginLabels = new HashMap>(); private static Map tryEndLabels = new HashMap(); private static Map labelCatchDepth = new HashMap(); + /* + * The entry labels of this method's catch handlers. A handler is entered by + * longjmp from throwException, which pops only the try block that MATCHED: a try + * statement with several catch clauses registers one block per clause, and the + * other clauses' blocks stayed registered. An exception thrown from the handler -- + * `catch (IOException e) { throw e; } catch (Exception e) {}` -- was then caught + * by its own sibling, on every native target. So each handler entry resets + * tryBlockOffset to the depth at its label, exactly as JUMP_TO does for a jump. + */ + private static Map handlerLabels = new HashMap(); // [ddyer 4/2017] convert this from a tree of strings to use the label itself // this fixes the problem of mysterious "statement expected" errors from builds, // caused because labels created by the assembler are not globally unique. @@ -108,6 +118,7 @@ public static void cleanup() tryBeginLabels.clear(); tryEndLabels.clear(); labelCatchDepth.clear(); + handlerLabels.clear(); usedLabels.clear(); labelNames.clear(); catchDepthInstructions = null; @@ -193,6 +204,12 @@ public static void addTryBeginLabel(Label l, String exception, int counter) { ll.add(new Pair(exception, counter)); } + /** Marks {@code l} as the entry of a catch handler; see handlerLabels. */ + public static void addHandlerLabel(Label l) { + handlerLabels.put(l, l); + labelIsUsed(l); + } + public static void addTryEndLabel(Label l) { Integer i = tryEndLabels.get(l); if(i == null) { @@ -240,6 +257,12 @@ public void appendInstruction(StringBuilder b) { b.append("\nlabel_"); b.append(labelName(parent)); b.append(":\n"); + if (handlerLabels.get(parent) != null && catchDepthInstructions != null) { + // Only the try blocks that enclose the handler's own code stay registered: + // its try statement's other clauses are not around it. + b.append("threadStateData->tryBlockOffset = methodBlockOffset + ") + .append(getLabelCatchDepth(parent, catchDepthInstructions)).append(";\n"); + } Integer tryCount = tryEndLabels.get(parent); if(tryCount != null) { // NOTE: Oct. 19, 2020 diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TryCatch.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TryCatch.java index cc67005524b..0c53584908b 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TryCatch.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/bytecodes/TryCatch.java @@ -108,6 +108,7 @@ public void appendInstruction(StringBuilder b, List instructions) { cid = "cn1_class_id_" + type.replace('/', '_').replace('$', '_'); } LabelInstruction.addTryBeginLabel(start, cid, counter); + LabelInstruction.addHandlerLabel(handler); // VOLATILE is load-bearing: both variables are assigned at TRY-ENTRY, // i.e. AFTER the setjmp in DEFINE_CATCH_BLOCK, and read back in the // catch handler -- which runs after a longjmp. C11 7.13.2.1: non-volatile diff --git a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js index 42f534f5d69..fd06b594fb9 100644 --- a/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js +++ b/vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js @@ -5855,7 +5855,11 @@ bindNative(["cn1_java_lang_String_charsToBytes_char_1ARRAY_char_1ARRAY_R_byte_1A } const out = jvm.newArray(encoded.length, "JAVA_BYTE", 1); for (let i = 0; i < encoded.length; i++) { - out[i] = encoded[i]; + // Sign-extended: a Java byte[] holds -128..127, and BALOAD reads the slot as + // it is. The encoder's 0..255 left every non-ASCII byte of getBytes() a value + // no Java byte can have, so comparisons, checksums and "< 0" tests disagreed + // with every other platform. + out[i] = (encoded[i] << 24) >> 24; } return out; }); diff --git a/vm/JavaAPI/src/java/lang/AssertionError.java b/vm/JavaAPI/src/java/lang/AssertionError.java index ab3ea949c07..1d8c080d879 100644 --- a/vm/JavaAPI/src/java/lang/AssertionError.java +++ b/vm/JavaAPI/src/java/lang/AssertionError.java @@ -23,26 +23,52 @@ package java.lang; public class AssertionError extends Error { + // Every constructor used to have an empty body, so the message -- the one + // thing an assertion failure says -- was dropped on every translated target, + // and a failing assert reported only its class. The JDK's rules: an Object + // message is its String value, and a Throwable one is also the cause. public AssertionError() { super(); } + public AssertionError(String detailMessage) { + super(detailMessage); } - + + // A null Object becomes the message "null", not a null message: measured on + // JDK 8 and JDK 21, whose constructor is this(String.valueOf(detailMessage)). public AssertionError(Object detailMessage) { + super(String.valueOf(detailMessage)); + if (detailMessage instanceof Throwable) { + initCause((Throwable) detailMessage); + } } - public AssertionError(boolean detailMessage) {} + public AssertionError(boolean detailMessage) { + super(String.valueOf(detailMessage)); + } - public AssertionError(char detailMessage) {} + public AssertionError(char detailMessage) { + super(String.valueOf(detailMessage)); + } - public AssertionError(int detailMessage) {} - - public AssertionError(long detailMessage) {} - - public AssertionError(float detailMessage) {} - - public AssertionError(double detailMessage) {} - - public AssertionError(String message, Throwable cause) {} + public AssertionError(int detailMessage) { + super(String.valueOf(detailMessage)); + } + + public AssertionError(long detailMessage) { + super(String.valueOf(detailMessage)); + } + + public AssertionError(float detailMessage) { + super(String.valueOf(detailMessage)); + } + + public AssertionError(double detailMessage) { + super(String.valueOf(detailMessage)); + } + + public AssertionError(String message, Throwable cause) { + super(message, cause); + } } diff --git a/vm/backend/demo/cn1ss/com/demo/Cn1ssScreenshotServer.java b/vm/backend/demo/cn1ss/com/demo/Cn1ssScreenshotServer.java deleted file mode 100644 index 9c357da00c7..00000000000 --- a/vm/backend/demo/cn1ss/com/demo/Cn1ssScreenshotServer.java +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright (c) 2012, 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. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.demo; - -import com.codename1.backend.HttpServer; -import com.codename1.backend.Json; -import com.codename1.backend.WebSocket; -import com.codename1.backend.WebSocketSession; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.util.HashMap; -import java.util.Map; - -/** - * The host-side screenshot receiver for the on-device test suites, on the - * Codename One backend. - * - * Every UI-test leg in this repository -- iOS, watchOS, tvOS, macOS, Catalyst, - * Android, Java SE and the browser -- runs its suite on a device and streams each - * captured PNG to this server over a websocket. Four independent client stacks - * reach it: NSURLSessionWebSocketTask on the Apple platforms, the hand-rolled - * Android and Java SE clients, and the browser's own WebSocket. That makes this - * the most thoroughly exercised websocket server in the tree, which is exactly why - * it is the one built on the backend: masked, fragmented, multi-hundred-kilobyte - * binary messages under ACK-paced flow control, on every CI run. - * - * ## The protocol, which is a contract with the device side - * - * Per screenshot the device sends a text frame - * - * META {"test":"<name>","png_bytes":<n>,"png_fnv1a64":"<hex>"} - * - * followed by the PNG as a binary message, and then blocks until this server - * answers `ACK <name> status=<ok|length_mismatch|hash_mismatch>`. The ACK is - * sent AFTER the bytes reach disk, and that ordering is the device's flow - * control -- sending it earlier lets the next test overwrite a file this one has - * not finished writing. - * - * `png_fnv1a64` is optional: the browser client omits it. - * - * ## Why the name is sanitised twice - * - * The name arrives over the wire, so it cannot be trusted even though the device - * helper already restricts it. It is folded to `[A-Za-z0-9_.-]`, refused outright - * if it holds a separator or is `.` or `..`, and the resolved path is then checked - * to be inside the output directory -- guarding the exact File that is written - * rather than a separately derived value, which is what CodeQL's - * java/path-injection asks for. - */ -public class Cn1ssScreenshotServer { - /** The port both ends hardcode. Nothing is injected per run. */ - private static final int DEFAULT_PORT = 8765; - - private static File outDir; - /** png hash to the first test that produced it, for the duplicate warning. */ - private static final Map HASHES = new HashMap(); - private static int received; - - public static void main(String[] args) throws Exception { - int port = DEFAULT_PORT; - String out = "cn1ss-out"; - boolean selfCheck = false; - for(int iter = 0 ; iter < args.length ; iter++) { - if("--port".equals(args[iter]) && iter + 1 < args.length) { - port = Integer.parseInt(args[++iter]); - } else if("--out".equals(args[iter]) && iter + 1 < args.length) { - out = args[++iter]; - } else if("--selfcheck".equals(args[iter])) { - selfCheck = true; - } - } - outDir = new File(out).getCanonicalFile(); - if(!outDir.isDirectory() && !outDir.mkdirs()) { - System.err.println("[cn1ss] cannot create " + outDir); - System.exit(2); - } - - HttpServer server = HttpServer.start("127.0.0.1", port, 64, 4, null, null, - new HttpServer.WebSocketRoutes() { - public void register(HttpServer.WebSocketRegistry registry) { - // Any path: the clients dial ws://host:8765 with no path, and the - // one the server sees depends on which client substitutes what. A - // fallback keeps that from being something four client stacks have - // to agree on. - registry.fallback(new HttpServer.WebSocketHandler() { - public WebSocket open(HttpServer.Request request) { - return new Receiver(); - } - }); - } - }); - - // The FIRST line, and the runner script polls for it before it starts the - // simulator. On the translated arm stdout is unbuffered already; the flush - // is for the JVM arm, so both behave alike. - System.out.println("CN1SS_SERVER_PORT=" + server.getPort()); - System.out.flush(); - - if(selfCheck) { - System.exit(SelfCheck.run(server.getPort(), outDir) ? 0 : 1); - } - server.awaitTermination(); - } - - /** One connection. There is normally exactly one, from the device under test. */ - static final class Receiver implements WebSocket { - private String pendingTest; - private long pendingBytes = -1; - private String pendingHash; - - public void onOpen(WebSocketSession session) { - } - - public void onText(WebSocketSession session, String message) throws IOException { - if(!message.startsWith("META ")) { - return; - } - Map meta; - try { - meta = Json.parseObject(message.substring(5)); - } catch (IOException err) { - System.err.println("[cn1ss] unparseable META: " + message); - return; - } - pendingTest = string(meta.get("test")); - pendingBytes = longOf(meta.get("png_bytes")); - pendingHash = string(meta.get("png_fnv1a64")); - } - - public void onBinary(WebSocketSession session, byte[] payload, int offset, int length) - throws IOException { - if(pendingTest == null) { - System.err.println("[cn1ss] binary frame with no META; dropping " + length + " bytes"); - return; - } - String test = pendingTest; - long declared = pendingBytes; - String declaredHash = pendingHash; - pendingTest = null; - pendingBytes = -1; - pendingHash = null; - writeAndAck(session, test, declared, declaredHash, payload, offset, length); - } - - public void onClose(WebSocketSession session, int code, String reason) { - } - - public void onError(WebSocketSession session, Exception error) { - System.err.println("[cn1ss] session error: " + error); - } - } - - static void writeAndAck(WebSocketSession session, String name, long declaredBytes, - String declaredHash, byte[] payload, int offset, int length) - throws IOException { - String safe = sanitise(name); - if(safe == null) { - System.err.println("[cn1ss] rejected unsafe test name: " + name); - session.sendText("NACK rejected status=invalid_test_name"); - return; - } - File target = new File(outDir, safe + ".png").getCanonicalFile(); - // Guarding the exact File the stream below opens. Checking a value derived - // separately from the one that is written leaves the sink unguarded as far - // as dataflow analysis is concerned, which is the shape CodeQL flags. - if(!isInside(outDir, target)) { - System.err.println("[cn1ss] rejected out-of-base path: " + name + " -> " + target); - session.sendText("NACK rejected status=path_escape"); - return; - } - - String status = "ok"; - StringBuilder warn = new StringBuilder(); - if(declaredBytes >= 0 && declaredBytes != length) { - status = "length_mismatch"; - warn.append("expected=").append(declaredBytes).append(",got=").append(length); - } - String actualHash = fnv1a64Hex(payload, offset, length); - if(declaredHash != null && !declaredHash.equals(actualHash)) { - if("ok".equals(status)) { - status = "hash_mismatch"; - } - if(warn.length() > 0) { - warn.append(';'); - } - warn.append("expected_hash=").append(declaredHash) - .append(",actual_hash=").append(actualHash); - } - Object previous = HASHES.get(actualHash); - if(previous == null) { - HASHES.put(actualHash, safe); - } else if(!previous.equals(safe)) { - // Two tests that produced pixel-identical images. This is what catches - // a stale frame being captured for a test that never rendered. - System.out.println("CN1SS:WARN:test=" + safe + " duplicate_image_with=" + previous - + " png_fnv1a64=" + actualHash); - } - - OutputStream stream = new FileOutputStream(target); - try { - stream.write(payload, offset, length); - } finally { - stream.close(); - } - received++; - System.out.println("CN1SS:INFO:test=" + safe + " png_bytes=" + length - + " png_fnv1a64=" + actualHash + " status=" + status - + (warn.length() == 0 ? "" : " warn=" + warn)); - // AFTER the bytes are on disk. The device blocks on this and moves to the - // next test when it arrives, so an earlier ACK races the file write. - session.sendText("ACK " + safe + " status=" + status); - } - - static int getReceived() { - return received; - } - - /** - * The name with every character outside `[A-Za-z0-9_.-]` replaced by `_`, or - * null when it cannot be a file name at all. - */ - static String sanitise(String name) { - if(name == null || name.length() == 0) { - return null; - } - if(name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 || name.indexOf('\0') >= 0) { - return null; - } - StringBuilder out = new StringBuilder(name.length()); - for(int iter = 0 ; iter < name.length() ; iter++) { - char c = name.charAt(iter); - boolean alphanumeric = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') - || (c >= '0' && c <= '9'); - out.append(alphanumeric || c == '_' || c == '.' || c == '-' ? c : '_'); - } - String result = out.toString(); - return ".".equals(result) || "..".equals(result) ? null : result; - } - - /** Whether `target` really sits under `base`, both already canonical. */ - static boolean isInside(File base, File target) { - String basePath = base.getPath(); - String targetPath = target.getPath(); - if(!targetPath.startsWith(basePath)) { - return false; - } - // "/out" must not be read as a prefix of "/output". - return targetPath.length() == basePath.length() - || targetPath.charAt(basePath.length()) == File.separatorChar; - } - - /** The same FNV-1a-64 the device computes, so the two can be compared. */ - static String fnv1a64Hex(byte[] bytes, int offset, int length) { - long hash = 0xcbf29ce484222325L; - long prime = 0x100000001b3L; - for(int iter = 0 ; iter < length ; iter++) { - hash ^= bytes[offset + iter] & 0xff; - hash *= prime; - } - StringBuilder out = new StringBuilder(16); - for(int shift = 60 ; shift >= 0 ; shift -= 4) { - int nibble = (int)((hash >>> shift) & 0xf); - out.append((char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10))); - } - return out.toString(); - } - - private static String string(Object value) { - return value == null ? null : String.valueOf(value); - } - - private static long longOf(Object value) { - if(value instanceof Number) { - return ((Number)value).longValue(); - } - if(value == null) { - return -1; - } - try { - return Long.parseLong(String.valueOf(value).trim()); - } catch (NumberFormatException err) { - return -1; - } - } -} diff --git a/vm/backend/demo/cn1ss/com/demo/SelfCheck.java b/vm/backend/demo/cn1ss/com/demo/SelfCheck.java deleted file mode 100644 index 79c318837b6..00000000000 --- a/vm/backend/demo/cn1ss/com/demo/SelfCheck.java +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright (c) 2012, 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. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.demo; - -import com.codename1.backend.Crypto; -import com.codename1.backend.Tcp; - -import java.io.File; -import java.io.IOException; - -/** - * Proves this binary really serves websockets, in about a second, before a CI leg - * spends forty minutes finding out otherwise. - * - * The readiness line the runner waits for only says the process bound a port. It - * does not say the handshake works, that the output directory is writable, that - * continuation reassembly is intact, or -- on the translated arm -- that the - * binary can execute on this machine at all. Every one of those has a failure mode - * that looks, from the runner's side, exactly like a device that captured no - * screenshots. - * - * So this opens a websocket to itself, sends a META frame and a binary message - * SPLIT ACROSS TWO FRAMES (the common path, since the hand-rolled clients fragment - * at 64KB), waits for the ACK, and checks the file landed. - */ -final class SelfCheck { - private static final String TEST_NAME = "__cn1ss_selfcheck__"; - /** RFC 6455 1.3's worked example, and the accept value it prints. */ - private static final String RFC_KEY = "dGhlIHNhbXBsZSBub25jZQ=="; - private static final String RFC_ACCEPT = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; - - private SelfCheck() { - } - - static boolean run(int port, File outDir) { - Tcp connection = null; - try { - connection = Tcp.connect("127.0.0.1", port, 5000); - // RFC 6455 1.3's own key, so the expected accept value is the one the - // standard prints. A random key would work too, but then this check - // could only compare the server against itself -- and the thing most - // worth catching here is a translated SHA-1 binding that is missing or - // wrong, which self-consistency cannot see. - String key = RFC_KEY; - String request = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n" - + "Upgrade: websocket\r\nConnection: Upgrade\r\n" - + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + key + "\r\n\r\n"; - byte[] requestBytes = ascii(request); - connection.write(requestBytes, 0, requestBytes.length); - - String head = readUntilBlankLine(connection); - if(!head.startsWith("HTTP/1.1 101")) { - System.err.println("[cn1ss] selfcheck: no 101, got " + firstLine(head)); - return false; - } - // THE ACCEPT VALUE, not just the status. A 101 says the server took - // the upgrade; it does not say the handshake is one a browser will - // accept. If Crypto.sha1's native went missing -- which is what - // CN1_BACKEND_HTTPS=0 does, and what a mistyped symbol does silently - // -- this server would still answer 101, every conforming client - // would reject it, and the leg would find out only after the - // forty-minute screenshot suite. - if(head.indexOf("Sec-WebSocket-Accept: " + RFC_ACCEPT) < 0) { - System.err.println("[cn1ss] selfcheck: wrong Sec-WebSocket-Accept; expected " - + RFC_ACCEPT + " for the RFC 6455 key. The SHA-1 binding is " - + "missing or wrong."); - return false; - } - - byte[] png = {(byte)0x89, 'P', 'N', 'G'}; - String hash = Cn1ssScreenshotServer.fnv1a64Hex(png, 0, png.length); - String meta = "META {\"test\":\"" + TEST_NAME + "\",\"png_bytes\":" + png.length - + ",\"png_fnv1a64\":\"" + hash + "\"}"; - writeFrame(connection, true, 0x1, ascii(meta)); - // Two frames for four bytes, on purpose: this is the reassembly path - // every real screenshot takes. - writeFrame(connection, false, 0x2, new byte[]{png[0], png[1]}); - writeFrame(connection, true, 0x0, new byte[]{png[2], png[3]}); - - String ack = readTextFrame(connection); - if(ack == null || !ack.startsWith("ACK " + TEST_NAME)) { - System.err.println("[cn1ss] selfcheck: expected an ACK, got " + ack); - return false; - } - if(ack.indexOf("status=ok") < 0) { - System.err.println("[cn1ss] selfcheck: " + ack); - return false; - } - File written = new File(outDir, TEST_NAME + ".png"); - if(!written.isFile() || written.length() != png.length) { - System.err.println("[cn1ss] selfcheck: " + written + " was not written"); - return false; - } - if(!written.delete()) { - System.err.println("[cn1ss] selfcheck: could not remove " + written); - return false; - } - System.out.println("CN1SS:INFO:selfcheck=ok"); - return true; - } catch (Exception err) { - System.err.println("[cn1ss] selfcheck failed: " + err); - return false; - } finally { - if(connection != null) { - connection.close(); - } - } - } - - /** A masked client frame, which is the only kind a server will accept. */ - private static void writeFrame(Tcp connection, boolean fin, int opcode, byte[] payload) - throws IOException { - byte[] mask = Crypto.randomBytes(4); - byte[] frame = new byte[2 + 4 + payload.length]; - frame[0] = (byte)((fin ? 0x80 : 0) | opcode); - frame[1] = (byte)(0x80 | payload.length); // selfcheck payloads are tiny - System.arraycopy(mask, 0, frame, 2, 4); - for(int iter = 0 ; iter < payload.length ; iter++) { - frame[6 + iter] = (byte)(payload[iter] ^ mask[iter & 3]); - } - connection.write(frame, 0, frame.length); - } - - private static String readTextFrame(Tcp connection) throws IOException { - int first = readByte(connection); - if(first < 0) { - return null; - } - int second = readByte(connection); - int length = second & 0x7f; - if(length == 126) { - length = (readByte(connection) << 8) | readByte(connection); - } - byte[] payload = new byte[length]; - readFully(connection, payload, length); - return new String(payload, 0, length, "UTF-8"); - } - - private static String readUntilBlankLine(Tcp connection) throws IOException { - StringBuilder out = new StringBuilder(); - while(out.length() < 8192) { - int c = readByte(connection); - if(c < 0) { - break; - } - out.append((char)c); - int length = out.length(); - if(length >= 4 && out.charAt(length - 4) == '\r' && out.charAt(length - 3) == '\n' - && out.charAt(length - 2) == '\r' && out.charAt(length - 1) == '\n') { - break; - } - } - return out.toString(); - } - - private static int readByte(Tcp connection) throws IOException { - byte[] one = new byte[1]; - int read = connection.read(one, 0, 1); - return read <= 0 ? -1 : one[0] & 0xff; - } - - private static void readFully(Tcp connection, byte[] buffer, int length) throws IOException { - int got = 0; - while(got < length) { - int read = connection.read(buffer, got, length - got); - if(read <= 0) { - throw new IOException("the connection ended after " + got + " of " + length); - } - got += read; - } - } - - private static String firstLine(String value) { - int end = value.indexOf('\r'); - return end < 0 ? value : value.substring(0, end); - } - - private static byte[] ascii(String value) { - byte[] out = new byte[value.length()]; - for(int iter = 0 ; iter < out.length ; iter++) { - out[iter] = (byte)value.charAt(iter); - } - return out; - } -} diff --git a/vm/backend/demo/oteltest/com/demo/OtelServer.java b/vm/backend/demo/oteltest/com/demo/OtelServer.java index 05226b063bd..a6cf37541d0 100644 --- a/vm/backend/demo/oteltest/com/demo/OtelServer.java +++ b/vm/backend/demo/oteltest/com/demo/OtelServer.java @@ -34,6 +34,7 @@ import com.codename1.backend.WebSocketSession; import com.codename1.backend.orm.EntityManager; import com.codename1.backend.otel.OtlpTracer; +import com.codename1.impl.backend.BackendAccess; /** * A traced server, for BackendOtelTest to drive on the PACKAGED runtime. @@ -58,15 +59,17 @@ public static void main(String[] args) throws Exception { final Database db = Database.open(":memory:"); db.execute("CREATE TABLE pets (id INTEGER PRIMARY KEY, name TEXT)", null); db.execute("INSERT INTO pets (id, name) VALUES (7, 'Rex')", null); - Backend.builder() - .tracing(new OtlpTracer("oteltest")) - // Both off at run time -- this profile is not a development one and - // there is no tool -- but LINKED, which is what BackendOtelTest - // needs: the control that proves its management and MCP symbol - // spellings match something. - .management() - .mcp(null) - .webSockets(new Backend.WebSocketEndpoints() { + Backend.Builder builder = Backend.builder() + .tracing(new OtlpTracer("oteltest")); + // Both off at run time -- this profile is not a development one and there + // is no tool -- but LINKED, which is what BackendOtelTest needs: the control + // that proves its management and MCP symbol spellings match something. + // Through BackendAccess, as the generated entry point links them: neither + // is a Builder method an application calls. + BackendAccess access = BackendAccess.get(); + access.management(builder); + access.mcp(builder, null); + builder.webSockets(new Backend.WebSocketEndpoints() { public void register(HttpServer.WebSocketRegistry registry, DataSource dataSource, EntityManager entities) { registry.route("/ws", new WebSocket() { diff --git a/vm/backend/demo/petserver/com/demo/PetServer.java b/vm/backend/demo/petserver/com/demo/PetServer.java index 662ae07e95e..0f25e303604 100644 --- a/vm/backend/demo/petserver/com/demo/PetServer.java +++ b/vm/backend/demo/petserver/com/demo/PetServer.java @@ -96,6 +96,31 @@ public static void main(String[] args) throws Exception { public HttpServer.Response handle(HttpServer.Request request) throws Exception { String method = request.getMethod(); String target = request.getTarget(); + // What the parser made of the body: text or bytes, its length, and + // the parts of a multipart form. The binary, gzip and multipart + // paths live in HttpServer, so this is where the translated server + // proves them. + if("/bodyecho".equals(stripQuery(target))) { + StringBuilder sb = new StringBuilder(); + byte[] bytes = request.getBodyBytes(); + sb.append("length=").append(bytes == null ? 0 : bytes.length); + int sum = 0; + for(int iter = 0 ; bytes != null && iter < bytes.length ; iter++) { + sum = sum * 31 + (bytes[iter] & 0xff); + } + sb.append(" sum=").append(sum); + java.util.List parts = request.getParts(); + for(int iter = 0 ; iter < parts.size() ; iter++) { + HttpServer.Part part = (HttpServer.Part) parts.get(iter); + sb.append(" part=").append(part.getName()).append('/') + .append(part.getFilename()).append('/').append(part.getSize()); + } + String field = request.param("field"); + if(field != null) { + sb.append(" field=").append(field); + } + return HttpServer.Response.text(200, sb.toString()); + } if("/healthz".equals(stripQuery(target))) { return HttpServer.Response.json(200, Json.write(serverRef[0].getMetrics())); } diff --git a/vm/backend/demo/selftest/com/demo/SelfTest.java b/vm/backend/demo/selftest/com/demo/SelfTest.java index d5c0ff88304..28d66bc0041 100644 --- a/vm/backend/demo/selftest/com/demo/SelfTest.java +++ b/vm/backend/demo/selftest/com/demo/SelfTest.java @@ -41,7 +41,7 @@ import com.codename1.backend.Http1Date; import com.codename1.backend.HttpServer; import com.codename1.backend.Json; -import com.codename1.backend.JsonCodec; +import com.codename1.impl.backend.JsonCodec; import com.codename1.backend.Jwt; import com.codename1.backend.ServerSocket; import com.codename1.backend.StaticFiles; @@ -316,7 +316,7 @@ private static void futures() throws Exception { check("future: a timed get of a completed value", "ready", String.valueOf(ready.get(1, java.util.concurrent.TimeUnit.SECONDS))); - com.codename1.backend.AsyncTask ran = new com.codename1.backend.AsyncTask( + com.codename1.impl.backend.AsyncTask ran = new com.codename1.impl.backend.AsyncTask( "selftest.ran", false) { protected Object call() { return com.codename1.backend.AsyncResult.of("ran"); @@ -326,7 +326,7 @@ protected Object call() { check("future: a task's result", "ran", String.valueOf(ran.get(30, java.util.concurrent.TimeUnit.SECONDS))); - com.codename1.backend.AsyncTask broken = new com.codename1.backend.AsyncTask( + com.codename1.impl.backend.AsyncTask broken = new com.codename1.impl.backend.AsyncTask( "selftest.broken", false) { protected Object call() { throw new IllegalStateException("broken"); @@ -341,7 +341,7 @@ protected Object call() { } check("future: a failure is the ExecutionException's cause", "broken", cause); - com.codename1.backend.AsyncTask cancelled = new com.codename1.backend.AsyncTask( + com.codename1.impl.backend.AsyncTask cancelled = new com.codename1.impl.backend.AsyncTask( "selftest.cancelled", false) { protected Object call() { return com.codename1.backend.AsyncResult.of("never"); @@ -356,7 +356,7 @@ protected Object call() { } check("future: get of a cancelled task", "cancelled", thrown); - com.codename1.backend.AsyncTask pending = new com.codename1.backend.AsyncTask( + com.codename1.impl.backend.AsyncTask pending = new com.codename1.impl.backend.AsyncTask( "selftest.pending", false) { protected Object call() { return com.codename1.backend.AsyncResult.of("never"); @@ -5656,39 +5656,22 @@ private static void aNestedFinallyDoesNotDefeatTheOuterCatch() throws Exception } check("a plain throw is caught in the same method", "caught", plain); - // 3. BOTH IN ONE METHOD: the catch encloses a try/finally. THIS ONE IS - // BROKEN on the packaged binary, which is why it does not run by default. - // - // Measured: it does not reach the catch at all. The binary prints - // "Uncaught exception java.io.IOException: thrown in the inner try" and - // exits 1, so it does not fail an assertion -- it takes the process with - // it, and every check after it in this file with it. A finally compiles to - // a catch-any that rethrows, and that rethrow is evidently not matched - // against the handlers enclosing it in the SAME method. Case 1 above is - // the same construct with the finally one frame down, and it is fine, - // which is why this is not visible in ordinary code: the usual shape puts - // the cleanup in the method that owns the resource. - // - // It is left here, runnable, because the reduced case is the whole bug - // report: set CN1_SELFTEST_VM_PROBE=1 to watch it die. Fixing it belongs - // in the translator's handler ranges, not here. - if("1".equals(System.getenv("CN1_SELFTEST_VM_PROBE"))) { - String sameMethod; + // 3. BOTH IN ONE METHOD: the catch encloses a try/finally. This was skipped + // by default as the case that killed the binary -- the finally's rethrow + // escaped the enclosing catch -- until the translator's catch-depth fix. + // vm/tests CatchRethrowIntegrationTest now covers it on every compiler + // configuration, so it runs here as an ordinary check. + String sameMethod; + try { try { - try { - throw new IOException("thrown in the inner try"); - } finally { - touched++; // the finally must run, and must not swallow - } - } catch (Exception caught) { - sameMethod = "caught"; + throw new IOException("thrown in the inner try"); + } finally { + touched++; // the finally must run, and must not swallow } - check("an enclosing catch sees a throw from a nested finally", "caught", - sameMethod); - } else { - note("nested-finally VM probe skipped: set CN1_SELFTEST_VM_PROBE=1 " - + "to run the case that kills the process"); + } catch (Exception caught) { + sameMethod = "caught"; } + check("an enclosing catch sees a throw from a nested finally", "caught", sameMethod); } /** diff --git a/vm/backend/impl/javase/com/codename1/backend/DevConsole.java b/vm/backend/impl/javase/com/codename1/impl/backend/DevConsole.java similarity index 99% rename from vm/backend/impl/javase/com/codename1/backend/DevConsole.java rename to vm/backend/impl/javase/com/codename1/impl/backend/DevConsole.java index 2f0e478e690..f763f7f1d6a 100644 --- a/vm/backend/impl/javase/com/codename1/backend/DevConsole.java +++ b/vm/backend/impl/javase/com/codename1/impl/backend/DevConsole.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; import java.io.OutputStream; import java.io.PrintStream; diff --git a/vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java b/vm/backend/impl/parparvm/com/codename1/impl/backend/DevConsole.java similarity index 97% rename from vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java rename to vm/backend/impl/parparvm/com/codename1/impl/backend/DevConsole.java index c79a7fddce4..97035ce0e8e 100644 --- a/vm/backend/impl/parparvm/com/codename1/backend/DevConsole.java +++ b/vm/backend/impl/parparvm/com/codename1/impl/backend/DevConsole.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; import java.util.ArrayList; import java.util.List; diff --git a/vm/backend/shared-sources.sh b/vm/backend/shared-sources.sh index 1dd1f828ab5..e25547739ff 100755 --- a/vm/backend/shared-sources.sh +++ b/vm/backend/shared-sources.sh @@ -12,7 +12,7 @@ # src/com/codename1/impl/orm/BackendSqlAccess.java, the server's implementation # of the shared SqlAccess interface -- never a second copy of a shared one. # -# Consumers: build.sh, run-javase.sh, ws-conformance.sh, scripts/lib/cn1ss.sh and +# Consumers: build.sh, run-javase.sh, ws-conformance.sh and # scripts/check-native-signatures.sh. maven/backend/pom.xml applies the SAME line # match with an Ant selector, so change both together. # diff --git a/vm/backend/src/com/codename1/backend/Access.java b/vm/backend/src/com/codename1/backend/Access.java new file mode 100644 index 00000000000..aa8c2baddd0 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Access.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import com.codename1.backend.orm.EntityManager; +import com.codename1.impl.backend.BackendAccess; +import com.codename1.impl.backend.BackendApplication; +import com.codename1.impl.backend.mcp.McpServer; +import com.codename1.impl.backend.mcp.McpTool; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The one implementation of [BackendAccess]: this package's internals, for the +/// generated code and the runtime's classes in other packages. Installed by +/// [Backend]'s static initializer; see BackendAccess for why it exists. +final class Access extends BackendAccess { + @Override + public void application(Backend.Builder builder, BackendApplication application) { + builder.application(application); + } + + @Override + public void mcp(Backend.Builder builder, McpServer.Extension devTools) { + builder.mcp(devTools); + } + + @Override + public void management(Backend.Builder builder) { + builder.management(); + } + + @Override + public void compiledSettings(Backend.Builder builder, String[] keysAndValues) { + builder.compiledSettings(keysAndValues); + } + + @Override + public Config testConfig(java.util.Properties values, String profile) { + return Config.overriding(values, profile); + } + + @Override + public void mcpTool(Backend.Builder builder, McpTool tool) { + builder.mcpTool(tool); + } + + @Override + public void serviceName(Backend.Builder builder, String name) { + builder.serviceName(name); + } + + @Override + public BackendApplication applicationOf(Backend backend) { + return backend.getApplication(); + } + + @Override + public List managedBeans(Backend backend) { + return backend.getManagedBeans(); + } + + @Override + public boolean isMeasured(Backend backend) { + return backend.isMeasured(); + } + + @Override + public void enableRequestLog(Backend backend, int capacity) { + backend.getRequestLog().enable(capacity); + } + + @Override + public List recentRequests(Backend backend, int limit, boolean failuresOnly) { + return backend.getRequestLog().recent(limit, failuresOnly); + } + + @Override + public HttpServer.Response dispatch(Backend backend, String method, String target, + Map headers, byte[] body) throws Exception { + Map lower = new LinkedHashMap(); + if (headers != null) { + Iterator it = headers.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + lower.put(asciiLower(String.valueOf(e.getKey())), e.getValue()); + } + } + HttpServer.Request request = new HttpServer.Request(method, target, "HTTP/1.1", lower, + null); + if (body != null && body.length > 0) { + Object[] content; + try { + content = HttpServer.bodyContent(request, body, 0, body.length); + } catch (IOException refused) { + return HttpServer.Response.text(HttpServer.refusalStatus(refused), + refused.getMessage()); + } + request.setBody((String) content[0], (byte[]) content[1]); + } + return backend.dispatch(request); + } + + /// A header name folded to lower case by hand: names are ASCII by + /// specification, and toLowerCase() is locale sensitive. + private static String asciiLower(String value) { + char[] chars = value.toCharArray(); + for (int iter = 0 ; iter < chars.length ; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + @Override + public int status(HttpServer.Response response) { + return response.status; + } + + @Override + public List headers(HttpServer.Response response) { + List out = new ArrayList(); + if (response.extraHeaders == null) { + return out; + } + Iterator it = response.extraHeaders.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + Object value = e.getValue(); + if (value instanceof List) { + List values = (List) value; + for (Object one : values) { + out.add(new String[] {String.valueOf(e.getKey()), String.valueOf(one)}); + } + } else if (value != null) { + out.add(new String[] {String.valueOf(e.getKey()), String.valueOf(value)}); + } + } + return out; + } + + @Override + public byte[] body(HttpServer.Response response) throws IOException { + response.serializeDeferredJson(); + if (response.fileFd >= 0) { + // The writer owns closing a file body, and there is no writer here. + try { + return StaticFiles.readAll(response.fileFd, response.fileOffset, + response.fileLength); + } finally { + response.discard(); + } + } + return response.body == null ? new byte[0] : response.body; + } + + @Override + public void discard(HttpServer.Response response) { + response.discard(); + } + + @Override + public String contentType(HttpServer.Response response) { + return response.contentType; + } + + @Override + public Object[] requestBeans(HttpServer.Request request, int count) { + return request.scopedBeans(count); + } + + @Override + public Object sessionBeanLock(HttpSession session) { + return session.beanLock(); + } + + @Override + public Object[] sessionBeans(HttpSession session, int count) { + return session.scopedBeans(count); + } + + @Override + public void napUntil(long deadlineMillis) { + HttpServer.napUntil(deadlineMillis); + } + + @Override + public Span captureParent() { + return Tracing.captureParent(); + } + + @Override + public Tracer captureOwner() { + return Tracing.captureOwner(); + } + + @Override + public Tracer untraced() { + return Tracing.NONE; + } + + @Override + public Object inBackground(String name, Span parent, Tracer own, Tracing.Work work) + throws Exception { + return Tracing.inBackground(name, parent, own, work); + } + + @Override + public Object begin(int propagation, boolean readOnly, int timeoutSeconds) { + return Transactions.begin(propagation, readOnly, timeoutSeconds); + } + + @Override + public void commit(Object transaction) { + if (transaction instanceof Transactions.Transaction) { + Transactions.commit((Transactions.Transaction) transaction); + } else { + throw new IllegalArgumentException("Not a transaction begin() returned"); + } + } + + @Override + public void afterThrow(Object transaction, boolean rollback) { + if (transaction instanceof Transactions.Transaction) { + Transactions.afterThrow((Transactions.Transaction) transaction, rollback); + } else { + throw new IllegalArgumentException("Not a transaction begin() returned"); + } + } + + @Override + public Database joined(DataSource pool) throws IOException { + return Transactions.joined(pool); + } + + @Override + public boolean isJoined(DataSource pool, Database db) { + return Transactions.isJoined(pool, db); + } + + @Override + public boolean isActiveOn(DataSource pool) { + return Transactions.isActiveOn(pool); + } + + @Override + public com.codename1.orm.session.Session session(EntityManager entities) { + return Transactions.session(entities); + } + + @Override + public void markRollbackOnly() { + Transactions.markRollbackOnly(); + } + + @Override + public void markRollbackOnly(DataSource pool) { + Transactions.markRollbackOnly(pool); + } + + @Override + public TaskExecutor executor(String name, int kind) { + return Tasks.executor(name, kind); + } + + @Override + public List executorsOf(Collection servers) { + return Tasks.executorsOf(servers); + } +} diff --git a/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index 5a0cec72610..700aef80530 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -22,13 +22,18 @@ */ package com.codename1.backend; +import com.codename1.backend.metrics.MetricReader; +import com.codename1.backend.orm.EntityDefinition; +import com.codename1.backend.orm.EntityManager; +import com.codename1.impl.backend.BackendApplication; +import com.codename1.impl.backend.Management; +import com.codename1.impl.backend.WiringEnvironment; +import com.codename1.impl.backend.mcp.McpServer; + import java.io.IOException; import java.util.ArrayList; import java.util.List; -import com.codename1.backend.orm.EntityDefinition; -import com.codename1.backend.orm.EntityManager; - /// A configured, running server: the twenty lines every main used to open with, /// written once. /// @@ -68,7 +73,16 @@ /// DATABASE_URL=postgres://app:secret@db.internal/app PORT=8080 ./server /// ``` public final class Backend { + static { + // First, so code in another package reaching for the internals while + // anything below initializes still finds them. + com.codename1.impl.backend.BackendAccess.install(new Access()); + } + private final HttpServer server; + /// What the listener hands every request to; [Access#dispatch] calls it + /// directly, for a test that answers requests without a socket. + private Serving serving; private final DataSource dataSource; private final EntityManager entities; private final Config config; @@ -79,9 +93,9 @@ public final class Backend { /// stopping this one must not shut that down. private final Tracer ownTracer; /// The generated wiring of this server's beans, or null. - private final Application application; + private final BackendApplication application; /// The metrics exporter this server started, or null. - private final com.codename1.backend.metrics.MetricReader metricReader; + private final MetricReader metricReader; /// This server's managed beans; see [#getManagedBeans]. private final List managedBeans; /// This server's session settings and store. @@ -95,7 +109,7 @@ public final class Backend { private boolean measured; /// Whether this server records request and job metrics. - public boolean isMeasured() { + boolean isMeasured() { return measured; } @@ -113,8 +127,8 @@ public Tracer getTracer() { private Backend(HttpServer server, DataSource dataSource, EntityManager entities, Config config, int shutdownMillis, Tracer ownTracer, - Application application, - com.codename1.backend.metrics.MetricReader metricReader, + BackendApplication application, + MetricReader metricReader, List managedBeans, Sessions sessions, Tasks.Registry tasks, RequestLog requestLog, List gauges) { this.gauges = gauges; @@ -122,14 +136,14 @@ private Backend(HttpServer server, DataSource dataSource, EntityManager entities try { for ( ; added < gauges.size() ; added++) { Object[] g = (Object[]) gauges.get(added); - com.codename1.backend.metrics.Metrics.addSource((String) g[0], (String) g[1], + com.codename1.impl.backend.MetricsAccess.get().addSource((String) g[0], (String) g[1], (String) g[2], (com.codename1.backend.metrics.Gauge.Source) g[3]); } } catch (RuntimeException err) { // A start that fails here must not leave the gauges it did add. for (int iter = 0 ; iter < added ; iter++) { Object[] g = (Object[]) gauges.get(iter); - com.codename1.backend.metrics.Metrics.removeSource((String) g[0], + com.codename1.impl.backend.MetricsAccess.get().removeSource((String) g[0], (com.codename1.backend.metrics.Gauge.Source) g[3]); } throw err; @@ -188,14 +202,14 @@ public Config getConfig() { } /// The build-generated wiring of this server's beans, or null when it has none. - public Application getApplication() { + BackendApplication getApplication() { return application; } /// The managed beans THIS server registered, as a copy. Per server rather than /// per process: a second server in the same process -- or this one started /// again -- must not list or invoke the beans of one that has stopped. - public List getManagedBeans() { + List getManagedBeans() { return new ArrayList(managedBeans); } @@ -209,15 +223,30 @@ synchronized void markReady() { } /// The log of this server's recent requests, off until something enables it. - public RequestLog getRequestLog() { + RequestLog getRequestLog() { return requestLog; } /// This server's sessions: their settings and the store they are kept in. - public Sessions getSessions() { + Sessions getSessions() { return sessions; } + /// Answers `request` as the listener would -- sessions, scoped beans, tracing + /// and metrics included -- on the calling thread, without a socket. A request + /// no handler answers is a 404, as on the wire. + HttpServer.Response dispatch(HttpServer.Request request) throws Exception { + Serving chain = serving; + if (chain == null) { + throw new IllegalStateException("This server is not serving"); + } + HttpServer.Response response = chain.handle(request); + if (response == null) { + response = HttpServer.Response.text(404, "Not Found"); + } + return response; + } + /// Blocks until the server stops. public void awaitTermination() { server.awaitTermination(); @@ -470,10 +499,10 @@ private void drain() { // a stopped server and pool -- AFTER the drain: a scheduled run that ends // in it records its duration and outcome, and with the last measured // server's metrics already off that record was silently dropped. - com.codename1.backend.metrics.Metrics.disableServer(server, dataSource); + com.codename1.impl.backend.MetricsAccess.get().disableServer(server, dataSource); for (Object element : gauges) { Object[] g = (Object[]) element; - com.codename1.backend.metrics.Metrics.removeSource((String) g[0], + com.codename1.impl.backend.MetricsAccess.get().removeSource((String) g[0], (com.codename1.backend.metrics.Gauge.Source) g[3]); } // This server's exporter too, BEFORE the beans: an export in progress @@ -561,62 +590,12 @@ HttpServer.Handler[] create(DataSource dataSource, EntityManager entities) throws Exception; } - /// The build-generated wiring of an application: every bean, constructed and - /// injected by straight-line code the build wrote, and the lifecycle calls - /// around them. - /// - /// Nothing here is looked up or reflected. The build resolves which - /// constructor each bean gets, which bean each injection point receives and - /// in what order they are built, and writes that down as `new` and - /// setter calls; this interface is only where the server calls into it. - public interface Application { - /// Constructs the beans and returns the routers, once the database, if - /// any, is open. - HttpServer.Handler[] create(Environment environment) throws Exception; - - /// Registers the websocket endpoints, which are beans too. - void registerWebSockets(HttpServer.WebSocketRegistry registry) throws Exception; - - /// The server is accepting: scheduled jobs and exporters start here. - void started(Backend backend) throws Exception; - - /// The server is about to drain: no new scheduled run starts after this. - void stopping(); - - /// The server has drained: the beans' destroy methods run here. - void stopped(); - - /// Whether a generated class needs [Backend#currentRequest]: a - /// request- or session-scoped bean reached from a singleton. False keeps - /// the per-request thread-local write out of servers that have none. - boolean tracksCurrentRequest(); - - /// A request has been answered; `beans` are its - /// `@RequestScope` beans, whose destroy methods run here. - void requestEnded(Object[] beans); - - /// A session has ended -- invalidated, expired, or the server stopped; - /// `beans` are its `@SessionScope` beans, whose destroy - /// methods run here. - void sessionEnded(Object[] beans); - - /// The scheduler running this application's `@Scheduled` jobs, or null. - Scheduler getScheduler(); - - /// Every bean the build wired: name, type, scope and what it was given. - /// For the management endpoint and the development MCP server. - List describeBeans(); - - /// Every route the build generated: method, path and handler. - List describeRoutes(); - } - /// The request the calling thread is serving, for generated scoped proxies. private static final ThreadLocal CURRENT_REQUEST = new ThreadLocal(); /// The request the calling thread is serving, or null outside one. Maintained - /// only for applications whose build asked for it -- see - /// [Application#tracksCurrentRequest]. + /// only for applications whose build asked for it: one with a request- or + /// session-scoped bean reached from a singleton. public static HttpServer.Request currentRequest() { return (HttpServer.Request) CURRENT_REQUEST.get(); } @@ -863,17 +842,24 @@ private static final class Serving implements HttpServer.Handler { private final Tasks.Registry tasks; private final RequestLog requestLog; private final java.util.concurrent.atomic.AtomicBoolean instrumented; - private final Application app; + private final BackendApplication app; private final boolean track; /// This server's tracer, or the untraced marker; bound for the request /// so work it hands to another thread -- an @Async call -- keeps it. private final Tracer tracer; /// Every request counted in and out, for a stop() one of them makes. private final InFlight inFlight; + /// `cn1.cors.*`, or null when CORS is off. + private final Cors cors; + /// `cn1.server.compression.*`, or null when compression is off. + private final Compression compression; Serving(HttpServer.Handler[] chain, Sessions sessions, Tasks.Registry tasks, RequestLog requestLog, java.util.concurrent.atomic.AtomicBoolean instrumented, - Application app, boolean track, Tracer tracer, InFlight inFlight) { + BackendApplication app, boolean track, Tracer tracer, InFlight inFlight, + Cors cors, Compression compression) { + this.cors = cors; + this.compression = compression; this.tracer = tracer; this.inFlight = inFlight; this.chain = chain; @@ -978,7 +964,7 @@ private void endRequestBeans(HttpServer.Request request, public HttpServer.Response handle(HttpServer.Request request) throws Exception { long started = instrumented.get() - ? com.codename1.backend.metrics.Metrics.requestStarted() + ? com.codename1.impl.backend.MetricsAccess.get().requestStarted() : 0L; Object previous = null; // This server's sessions, not a process-wide set: @@ -1004,11 +990,15 @@ public HttpServer.Response handle(HttpServer.Request request) HttpServer.Response response = null; List attempted = new ArrayList(2); try { - for (HttpServer.Handler element : chain) { - response = element.handle(request); - if (response != null) { - break; - } + // A cross-origin request the CORS policy does not admit never + // reaches a handler. + response = cors == null ? null : cors.reject(request); + for (int i = 0 ; response == null && i < chain.length ; i++) { + response = chain[i].handle(request); + } + if (response == null && cors != null) { + // A preflight no handler took: the policy answers it. + response = cors.preflight(request); } // Request beans end BEFORE the session is // stored: a @PreDestroy that changes the @@ -1064,6 +1054,21 @@ public HttpServer.Response handle(HttpServer.Request request) failed(request, attempted, startedMillis, err); throw err; } + if (response != null && (cors != null || compression != null)) { + // A copy first. The handler may return one Response for every + // request -- a static final constant -- and both policies write + // into the object they are handed: compression replaced its body + // with gzip for good, so the next client that never offered gzip + // got it anyway, and CORS pinned the first allowed origin onto + // it. withHeaders shares the map, and every write replaces it. + response = response.withHeaders(response.extraHeaders); + } + if (cors != null) { + cors.decorate(request, response); + } + if (compression != null) { + compression.apply(request, response); + } // Null is a 404 from here, which is what a // router answers for a path it does not route. requestLog.record(request, response == null ? 404 @@ -1076,7 +1081,7 @@ public HttpServer.Response handle(HttpServer.Request request) // route label it set is cleared -- left // behind, the worker's next unrouted // request would be recorded under it. - com.codename1.backend.metrics.Metrics.requestEnded(started, + com.codename1.impl.backend.MetricsAccess.get().requestEnded(started, request.getMethod(), status); // Destroyed while this is still the current // request: a @PreDestroy that calls another @@ -1156,91 +1161,6 @@ static boolean isLoopback(String host) { return octets == 4; } - /// What an [Application] is built from. - public static final class Environment { - private final Config config; - private final DataSource dataSource; - private final EntityManager entities; - - private final List tools; - private final List managed; - /// {name, description, unit, Gauge.Source}, for the server to add and later remove. - final List gauges = new ArrayList(); - - Environment(Config config, DataSource dataSource, EntityManager entities, - List tools, List managed) { - this.config = config; - this.dataSource = dataSource; - this.entities = entities; - this.tools = tools; - this.managed = managed; - } - - /// Publishes an `@McpTool` on this server's MCP endpoint. Generated - /// code calls this while it builds the beans; the tool belongs to this - /// server only, so a server started later in the same process does not - /// serve a tool bound to a bean that has been destroyed. - public void registerTool(com.codename1.backend.mcp.McpTool tool) { - for (Object element : tools) { - if (((com.codename1.backend.mcp.McpTool) element).name() - .equals(tool.name())) { - // Two active beans publishing one name: one would be - // unreachable, and which depends on construction order. - throw new IllegalStateException("Two MCP tools are named \"" - + tool.name() + "\"; give one a distinct name"); - } - } - tools.add(tool); - } - - /// Publishes a managed attribute as a gauge of this server's: added when - /// the server starts and removed when it stops. Generated code calls this. - public void registerGauge(String name, String description, String unit, - com.codename1.backend.metrics.Gauge.Source source) { - gauges.add(new Object[] {name, description, unit, source}); - } - - /// Registers a managed bean with this server. Generated code calls this. - public void registerManaged(ManagedBean bean) { - String objectName = bean.getObjectName(); - if (objectName == null || objectName.length() == 0 || objectName.length() > 128) { - throw new IllegalArgumentException("A managed bean needs a name of 1 to 128 " - + "characters"); - } - for (int iter = 0 ; iter < objectName.length() ; iter++) { - char c = objectName.charAt(iter); - if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') - || c == '_' || c == '.' || c == '-')) { - // One URL segment, matched undecoded; see the build's check. - throw new IllegalArgumentException("Managed bean \"" + objectName - + "\": a name is letters, digits, _, - and . only"); - } - } - for (Object element : managed) { - if (((ManagedBean) element).getObjectName() - .equals(bean.getObjectName())) { - throw new IllegalStateException("Two managed resources are named \"" - + bean.getObjectName() + "\"; set objectName on one"); - } - } - managed.add(bean); - } - - public Config getConfig() { - return config; - } - - /// The pool, or null when this server has no database. - public DataSource getDataSource() { - return dataSource; - } - - /// The entity manager, or null when the build generated no entities. - public EntityManager getEntityManager() { - return entities; - } - } - /// Where a server's websocket endpoints come from. /// /// Deliberately the same shape as [Handlers]: the server calls this once @@ -1298,15 +1218,15 @@ public static final class Builder { private boolean createTables; private final List mcpTools = new ArrayList(); /// The application a start in progress has begun building, until a Backend owns it. - private Application createdApplication; + private BackendApplication createdApplication; /// The executors a start in progress opened, until a Backend owns them. private Tasks.Registry startingTasks; private boolean createTablesGiven; private boolean handlersNeedADatabase; private boolean quiet; private Tracer tracer; - private Application application; - private com.codename1.backend.metrics.MetricReader metricReader; + private BackendApplication application; + private MetricReader metricReader; private OwnRoute managementRoute; private OwnRoute mcpRoute; private String[] compiledSettings; @@ -1327,8 +1247,8 @@ public Builder handler(HttpServer.Handler handler) { } /// The build-generated wiring of this server's beans. See - /// [Application]; the generated entry point calls this. - public Builder application(Application application) { + /// [BackendApplication]; the generated entry point calls this. + Builder application(BackendApplication application) { this.application = application; return this; } @@ -1496,7 +1416,7 @@ public Builder tracing(Tracer tracer) { /// Exports metrics with this reader, once `open` has read the /// configuration and agreed to. The build calls this from the entry point /// of a project that enables OpenTelemetry. - public Builder metrics(com.codename1.backend.metrics.MetricReader reader) { + public Builder metrics(MetricReader reader) { this.metricReader = reader; return this; } @@ -1504,18 +1424,18 @@ public Builder metrics(com.codename1.backend.metrics.MetricReader reader) { /// Serves the MCP endpoint, with the application's `@McpTool` /// methods and, when `devTools` is given and the profile is a /// development one, the development tools. The build calls this; see - /// [com.codename1.backend.mcp.McpServer]. + /// [McpServer]. /// /// This method is the only code that names the endpoint's classes. The /// generated entry point calls it only for a build that asked for MCP, and /// the translator drops a method nothing calls -- so a server that did not /// ask has none of the endpoint in its binary. - public Builder mcp(final com.codename1.backend.mcp.McpServer.Extension devTools) { + Builder mcp(final McpServer.Extension devTools) { this.mcpRoute = new OwnRoute() { @Override HttpServer.Handler open(Config config, String name, List tools) throws IOException { - return com.codename1.backend.mcp.McpServer.fromConfig(config, devTools, + return McpServer.fromConfig(config, devTools, name, tools); } @@ -1523,19 +1443,19 @@ HttpServer.Handler open(Config config, String name, List tools) // anything else. @Override void attach(HttpServer.Handler opened, Backend running) { - ((com.codename1.backend.mcp.McpServer) opened).attach(running); + ((McpServer) opened).attach(running); } @Override String unguardedBy(HttpServer.Handler opened) { - return ((com.codename1.backend.mcp.McpServer) opened).hasToken() ? null - : com.codename1.backend.mcp.McpServer.TOKEN; + return ((McpServer) opened).hasToken() ? null + : McpServer.TOKEN; } @Override String announce(HttpServer.Handler opened, String base) { - com.codename1.backend.mcp.McpServer server = - (com.codename1.backend.mcp.McpServer) opened; + McpServer server = + (McpServer) opened; return "cn1: MCP endpoint at " + base + server.getPath() + (server.hasDevTools() ? " (with development tools)" : ""); } @@ -1547,7 +1467,7 @@ String announce(HttpServer.Handler opened, String base) { /// beans -- when the configuration turns them on; see [Management]. Like /// [#mcp], this is the only code that names them, and the generated entry /// point calls it only for a build that asked for them. - public Builder management() { + Builder management() { this.managementRoute = new OwnRoute() { @Override HttpServer.Handler open(Config config, String name, List tools) @@ -1566,14 +1486,14 @@ void attach(HttpServer.Handler opened, Backend running) { /// Settings compiled in from the settings annotations, as key and value /// pairs: the bottom layer of the configuration, below the properties /// files and the environment. The build calls this. - public Builder compiledSettings(String[] keysAndValues) { + Builder compiledSettings(String[] keysAndValues) { this.compiledSettings = keysAndValues; return this; } /// Adds a tool of the program's own to the MCP endpoint, beside the /// `@McpTool` methods the build found. Needs [#mcp]. - public Builder mcpTool(com.codename1.backend.mcp.McpTool tool) { + Builder mcpTool(com.codename1.impl.backend.mcp.McpTool tool) { if (tool == null) { throw new IllegalArgumentException("No tool"); } @@ -1582,7 +1502,7 @@ public Builder mcpTool(com.codename1.backend.mcp.McpTool tool) { } /// The name this server reports itself as, to MCP clients. - public Builder serviceName(String name) { + Builder serviceName(String name) { this.serviceName = name; return this; } @@ -1692,7 +1612,7 @@ private void abandonStart(DataSource pool) { // same as whether anything was configured: .dataSource(url) // makes the builder open one, and reading "was one configured" here // left exactly that case leaking on a failed start. - Application built = createdApplication; + BackendApplication built = createdApplication; createdApplication = null; Tasks.Registry tasks = startingTasks; startingTasks = null; @@ -1775,9 +1695,9 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // the first server's beans into the second. List tools = new ArrayList(mcpTools); List managedBeans = new ArrayList(); - Environment environment = null; + WiringEnvironment environment = null; if (application != null) { - environment = new Environment(config, pool, manager, tools, managedBeans); + environment = new WiringEnvironment(config, pool, manager, tools, managedBeans); HttpServer.Handler[] built = application.create(environment); if (built != null) { for (HttpServer.Handler element : built) { @@ -1800,7 +1720,8 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // handler. With no address chosen the listener would bind every // interface, so it binds loopback instead; an address chosen // explicitly that is not loopback needs the token. - String bindHost = host; + String bindHost = host != null && host.length() > 0 ? host + : config.get(Config.SERVER_ADDRESS, null); String unguardedBy = mcpServer == null ? null : mcpRoute.unguardedBy(mcpServer); if (unguardedBy != null) { if (bindHost == null || bindHost.length() == 0) { @@ -1920,6 +1841,7 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { // context exists to leak there. This is a packaged-runtime path. boolean ownsContext = context != null && tls == null; HttpServer server; + Serving serving; // For EVERY server, handler-only ones too: their handlers can call // getSession(), and skipping the settings would, among other things, // send a TLS server's session cookie without Secure. @@ -1938,7 +1860,7 @@ private Backend startWith(DataSource pool, Tracer active) throws Exception { final java.util.concurrent.atomic.AtomicBoolean instrumented = new java.util.concurrent.atomic.AtomicBoolean(); boolean bound = false; - final Application app = application; + final BackendApplication app = application; final boolean track = application != null && application.tracksCurrentRequest(); try { HttpServer.WebSocketRoutes routes = null; @@ -1964,10 +1886,17 @@ public void register(HttpServer.WebSocketRegistry direct) } }; } + Cors cors = Cors.fromConfig(config); + if (cors != null) { + // Whether a request arrives over TLS, for telling a same-origin + // request from a cross-origin one: the scheme is part of an origin. + cors.servedOverTls(context != null); + } + serving = new Serving(chain, sessions, tasks, requestLog, instrumented, app, + track, active != null ? active : Tracing.NONE, inFlight, + cors, Compression.fromConfig(config)); server = HttpServer.start(bindHost, listenPort, listenBacklog, workerCount, - new Serving(chain, sessions, tasks, requestLog, instrumented, app, track, - active != null ? active : Tracing.NONE, inFlight), - context, routes, active != null ? active : Tracing.NONE); + serving, context, routes, active != null ? active : Tracing.NONE); bound = true; } finally { if (!bound && ownsContext) { @@ -2009,14 +1938,15 @@ public void register(HttpServer.WebSocketRegistry direct) measuring |= readerOpen; } if (measuring) { - com.codename1.backend.metrics.Metrics.enableServer(server, pool); + com.codename1.impl.backend.MetricsAccess.get().enableServer(server, pool); instrumented.set(true); } backend = new Backend(server, pool, manager, config, drain, active, application, readerOpen ? metricReader : null, managedBeans, sessions, tasks, requestLog, - environment == null ? new ArrayList() : environment.gauges); + environment == null ? new ArrayList() : environment.gauges()); + backend.serving = serving; owned = true; } finally { if (!owned) { @@ -2025,7 +1955,7 @@ public void register(HttpServer.WebSocketRegistry direct) // built session-scoped beans; nothing later destroys them -- // the outer clean-up ends only the singletons, and after them. sessions.close(); - com.codename1.backend.metrics.Metrics.disableServer(server, pool); + com.codename1.impl.backend.MetricsAccess.get().disableServer(server, pool); if (readerOpen) { metricReader.shutdown(0); } @@ -2176,7 +2106,11 @@ private DataSource openDataSource() throws IOException { config.getInt(Config.DATASOURCE_BUSY_MILLIS, 5000), config.getInt(Config.DATASOURCE_BORROW_MILLIS, 10000)); } - boolean configured = config.get(Config.DATASOURCE_URL) != null; + // Resolves rather than get: a compiled-in ${DATABASE_URL} that this + // process does not set is not a database it was asked for, and a + // server that needs none starts. One that needs one fails below, in + // fromConfig, with the message naming the variable. + boolean configured = config.resolves(Config.DATASOURCE_URL); if (!configured && !handlersNeedADatabase && EntityManager.registered().length == 0) { return null; } diff --git a/vm/backend/src/com/codename1/backend/Compression.java b/vm/backend/src/com/codename1/backend/Compression.java new file mode 100644 index 00000000000..74bbbd45b13 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Compression.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// gzip for the application's responses, from `cn1.server.compression.*` -- the +/// same three settings Spring Boot's `server.compression` has, and like it off +/// unless enabled: compression trades CPU for bytes, and a server behind a proxy +/// that already compresses pays twice. +/// +/// A response is compressed when the client accepts gzip, its type is one of the +/// configured MIME types, its body is at least the minimum size, it is not a +/// file streamed from disk (that path is zero-copy, and compressing it would +/// read the whole file into memory), and its handler set no Content-Encoding of +/// its own. +final class Compression { + static final String ENABLED = "cn1.server.compression.enabled"; + static final String MIN_SIZE = "cn1.server.compression.minResponseSize"; + static final String MIME_TYPES = "cn1.server.compression.mimeTypes"; + + private static final String DEFAULT_TYPES = "text/html, text/xml, text/plain, text/css, " + + "text/javascript, application/javascript, application/json, application/xml"; + + private final int minSize; + private final List types; + + private Compression(int minSize, List types) { + this.minSize = minSize; + this.types = types; + } + + /// The configured policy, or null when compression is off. + static Compression fromConfig(Config config) throws IOException { + if (!config.getBoolean(ENABLED, false)) { + return null; + } + int min = config.getInt(MIN_SIZE, 2048); + if (min < 0) { + throw new IOException(MIN_SIZE + " is " + min + "; it can't be negative"); + } + String listed = config.get(MIME_TYPES, DEFAULT_TYPES); + List types = new ArrayList(); + int pos = 0; + while (pos <= listed.length()) { + int comma = listed.indexOf(',', pos); + int end = comma < 0 ? listed.length() : comma; + String type = listed.substring(pos, end).trim(); + if (type.length() > 0) { + types.add(type); + } + if (comma < 0) { + break; + } + pos = comma + 1; + } + return new Compression(min, types); + } + + /// Compresses `response` in place when the rules above allow it. + void apply(HttpServer.Request request, HttpServer.Response response) throws IOException { + // A 206 (or anything carrying Content-Range) is a range of the identity + // representation: compressing it would leave the offsets describing bytes + // the client never receives, and a resumed download assembled corrupt. + if (response == null || response.fileFd >= 0 + || response.status == 204 || response.status == 304 || response.status == 206 + || response.hasHeader("Content-Range") + || response.hasHeader("Content-Encoding") || !compressible(response.contentType)) { + return; + } + // A strong ETag names these exact bytes, and the gzip variant is other + // bytes under the same tag, which breaks If-Range and revalidation across + // the two. Left uncompressed, as Tomcat (Spring Boot's default server) + // leaves it: its noCompressionStrongETag is on by default. + String etag = response.headerValue("ETag"); + if (etag != null && !etag.trim().startsWith("W/")) { + return; + } + response.serializeDeferredJson(); + byte[] body = response.body; + if (body == null || body.length < minSize) { + return; + } + // Chosen by Accept-Encoding from here on, whichever way it goes: the + // identity answer varies too, or a shared cache that stored it for a + // client without gzip serves it to every client after. + response.appendToken("Vary", "Accept-Encoding"); + if (!acceptsGzip(request)) { + return; + } + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(body.length / 3 + 64); + com.codename1.io.gzip.GZIPOutputStream gzip = + new com.codename1.io.gzip.GZIPOutputStream(out); + gzip.write(body, 0, body.length); + gzip.close(); + response.body = out.toByteArray(); + response.header("Content-Encoding", "gzip"); + } + + private boolean compressible(String contentType) { + if (contentType == null) { + return false; + } + int semi = contentType.indexOf(';'); + String type = (semi < 0 ? contentType : contentType.substring(0, semi)).trim(); + for (Object listed : types) { + if (type.equalsIgnoreCase((String) listed)) { + return true; + } + } + return false; + } + + /// Whether Accept-Encoding lists gzip with a quality above zero (RFC 9110 + /// 12.5.3). An explicit gzip entry decides, wherever it sits in the list; `*` + /// counts only when gzip is not named. Returning at the first match let + /// `*;q=1, gzip;q=0` -- a client refusing gzip outright -- be sent gzip, and + /// `*;q=0, gzip` be refused it. + static boolean acceptsGzip(HttpServer.Request request) { + String accept = request.getHeader("Accept-Encoding"); + if (accept == null) { + return false; + } + int explicit = 0; // 0 unnamed, 1 accepted, -1 refused + int wildcard = 0; + int pos = 0; + while (pos <= accept.length()) { + int comma = accept.indexOf(',', pos); + int end = comma < 0 ? accept.length() : comma; + String item = accept.substring(pos, end).trim(); + int semi = item.indexOf(';'); + String coding = (semi < 0 ? item : item.substring(0, semi)).trim(); + boolean accepted = semi < 0 || !zeroQuality(item.substring(semi + 1)); + if ("gzip".equalsIgnoreCase(coding) || "x-gzip".equalsIgnoreCase(coding)) { + // gzip and its x-gzip alias are one coding: either accepting it is enough. + explicit = accepted || explicit == 1 ? 1 : -1; + } else if ("*".equals(coding)) { + wildcard = accepted ? 1 : -1; + } + if (comma < 0) { + break; + } + pos = comma + 1; + } + return explicit != 0 ? explicit == 1 : wildcard == 1; + } + + private static boolean zeroQuality(String params) { + String q = Multipart.parameter(params, "q"); + if (q == null) { + return false; + } + q = q.trim(); + for (int iter = 0 ; iter < q.length() ; iter++) { + char c = q.charAt(iter); + if (c != '0' && c != '.') { + return false; + } + } + return q.length() > 0; + } +} diff --git a/vm/backend/src/com/codename1/backend/Config.java b/vm/backend/src/com/codename1/backend/Config.java index f056d5075f3..47d272a90a7 100644 --- a/vm/backend/src/com/codename1/backend/Config.java +++ b/vm/backend/src/com/codename1/backend/Config.java @@ -68,13 +68,17 @@ /// /// 5. `application.properties`; /// -/// 6. what the build compiled in from the settings annotations -- -/// `@ServerConfig`, `@SessionConfig` and the rest of -/// `com.codename1.backend.annotations` -- so a file or the environment can -/// still change anything the source says; +/// 6. the module's `application.properties` as the build compiled it into the +/// server, so a binary with no file beside it still has the committed +/// settings, and a file or the environment can still change any of them; /// /// 7. the default the caller passed in. /// +/// A test started by `@BackendTest` adds one layer above all of these: the +/// settings the test itself names. A test that asks for a free port or an +/// in-memory database gets one even in a shell that exports `PORT` or +/// `DATABASE_URL`. +/// /// A value may reference an environment variable as `${NAME}` or /// `${NAME:fallback}`. That resolution happens when the value is READ /// rather than when the file is loaded, which is what lets a committed @@ -96,6 +100,9 @@ public final class Config { /// The port to listen on. Also read from PORT. public static final String SERVER_PORT = "cn1.server.port"; + /// The address to listen on: `127.0.0.1` for this machine only. Unset, the + /// server listens on every interface. + public static final String SERVER_ADDRESS = "cn1.server.address"; /// The listen backlog. public static final String SERVER_BACKLOG = "cn1.server.backlog"; /// The size of the request thread pool. @@ -168,8 +175,14 @@ public final class Config { private final Properties profileFile; private final Properties baseFile; private final Properties compiled; + private final Properties overrides; private final String profile; private final List loadedFrom; + /// Where load() read the files, or null for a configuration with none. + private String directory; + /// Whether the process or the base file named the profile; when neither did, + /// a compiled-in `cn1.profile` is the one that applies. + private boolean profileChosen = true; private Config(Properties baseFile, Properties profileFile, String profile, List loadedFrom) { this(baseFile, profileFile, new Properties(), profile, loadedFrom); @@ -177,6 +190,12 @@ private Config(Properties baseFile, Properties profileFile, String profile, List private Config(Properties baseFile, Properties profileFile, Properties compiled, String profile, List loadedFrom) { + this(baseFile, profileFile, compiled, new Properties(), profile, loadedFrom); + } + + private Config(Properties baseFile, Properties profileFile, Properties compiled, + Properties overrides, String profile, List loadedFrom) { + this.overrides = overrides; this.baseFile = baseFile; this.profileFile = profileFile; this.compiled = compiled; @@ -187,7 +206,7 @@ private Config(Properties baseFile, Properties profileFile, Properties compiled, /// This configuration over a bottom layer of compiled-in values: pairs of /// key and value, in that order, which every other layer overrides. The /// generated entry point passes what the settings annotations say. - public Config withCompiledDefaults(String[] keysAndValues) { + Config withCompiledDefaults(String[] keysAndValues) throws IOException { if (keysAndValues == null || keysAndValues.length == 0) { return this; } @@ -199,7 +218,28 @@ public Config withCompiledDefaults(String[] keysAndValues) { for (int iter = 0 ; iter < keysAndValues.length ; iter += 2) { merged.setProperty(keysAndValues[iter], keysAndValues[iter + 1]); } - return new Config(baseFile, profileFile, merged, profile, loadedFrom); + String active = profile; + Properties activeFile = profileFile; + String compiledProfile = merged.getProperty(PROFILE); + if (!profileChosen && compiledProfile != null) { + // The packaged server's committed application.properties names the + // profile, and nothing above it does: no CN1_PROFILE, no file beside + // the binary. load() had already settled on "default" by then, so the + // binary ran -- @Profile beans, datasource defaults, management -- as a + // profile its own settings never asked for. Expanded against the + // compiled layer, as a file's value is against its file. + String named = new Config(baseFile, new Properties(), merged, overrides, "default", + new ArrayList()).expand(compiledProfile, PROFILE, 0); + if (named.length() > 0 && !named.equals(profile)) { + active = named; + activeFile = directory == null ? new Properties() + : read(directory, "application-" + named + ".properties", loadedFrom); + } + } + Config out = new Config(baseFile, activeFile, merged, overrides, active, loadedFrom); + out.directory = directory; + out.profileChosen = profileChosen; + return out; } /// Reads the configuration for this process: the active profile, then the two @@ -217,6 +257,7 @@ public static Config load(String directory) throws IOException { // file gets a vote: a project whose default is development says so once, // in the file, rather than in every developer's shell. String profile = fromProcess(PROFILE); + boolean chosen = profile != null || base.getProperty(PROFILE) != null; if (profile == null) { profile = base.getProperty(PROFILE); if (profile != null) { @@ -241,7 +282,10 @@ public static Config load(String directory) throws IOException { } Properties profileFile = read(directory, "application-" + profile + ".properties", loadedFrom); - return new Config(base, profileFile, profile, loadedFrom); + Config out = new Config(base, profileFile, profile, loadedFrom); + out.directory = directory; + out.profileChosen = chosen; + return out; } /// A configuration with no files behind it, holding exactly what it is given. @@ -254,6 +298,19 @@ public static Config of(Properties values, String profile) { new ArrayList()); } + /// A configuration whose `values` win over every other layer, the process + /// environment included: what a test names for itself. Reached through + /// `BackendAccess` by the test support, and by nothing a server runs. + static Config overriding(Properties values, String profile) { + Properties top = new Properties(); + if (values != null) { + top.putAll(values); + } + return new Config(new Properties(), new Properties(), new Properties(), top, + profile == null || profile.length() == 0 ? "default" : profile, + new ArrayList()); + } + /// The active profile: "default" unless something named another. public String getProfile() { return profile; @@ -398,6 +455,10 @@ public List keys() { while (e.hasMoreElements()) { names.add(e.nextElement()); } + e = overrides.propertyNames(); + while (e.hasMoreElements()) { + names.add(e.nextElement()); + } e = baseFile.propertyNames(); while (e.hasMoreElements()) { names.add(e.nextElement()); @@ -409,9 +470,40 @@ public List keys() { return new ArrayList(names); } + /// Whether `key` has a value: false when no layer sets it, or when what is set + /// refers to a variable this process does not have and gives no fallback. + /// + /// For a setting the server consults only to learn whether it was asked for -- + /// the database, when no handler and no entity needs one. The committed + /// application.properties names production's database as `${DATABASE_URL}`, + /// and the build compiles that file into the binary; a server that needs no + /// database must still start where that variable is unset. Code that DOES + /// read the value gets the loud failure from [#get(String)], as before. + boolean resolves(String key) throws IOException { + if (raw(key) == null) { + return false; + } + try { + return get(key) != null; + } catch (UnsetReference unset) { + return false; + } + } + + /// A `${NAME}` with no fallback whose variable is not set. + private static final class UnsetReference extends IOException { + UnsetReference(String message) { + super(message); + } + } + /// The value as written, before any ${} in it is resolved. private String raw(String key) { - String value = fromProcess(key); + String value = overrides.getProperty(key); + if (value != null) { + return value; + } + value = fromProcess(key); if (value != null) { return value; } @@ -527,7 +619,7 @@ private String expand(String value, String key, int depth) throws IOException { // LOUDLY. Left alone, the caller opens a database named // "${DATABASE_URL}" -- or, worse, a SQLite file by that name, // which succeeds and is empty. - throw new IOException(key + " refers to ${" + reference + "}, which is " + throw new UnsetReference(key + " refers to ${" + reference + "}, which is " + "not set in the environment or the configuration. Set it, or " + "give it a fallback as ${" + reference + ":value}."); } diff --git a/vm/backend/src/com/codename1/backend/Cors.java b/vm/backend/src/com/codename1/backend/Cors.java new file mode 100644 index 00000000000..aa5f481194c --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Cors.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Cross-origin resource sharing for the application's routes, from `cn1.cors.*`. +/// +/// Off unless `cn1.cors.allowedOrigins` names something, because the default a +/// browser applies -- no cross-origin reads -- is the safe one and turning it off +/// is a decision. A page served from one origin calling the API on another (a +/// web app on its own port, a CDN in front of a separate API host) needs it on. +/// +/// A preflight is answered only when no handler answered the OPTIONS request +/// itself, so an endpoint with an origin policy of its own -- the MCP endpoint -- +/// keeps it. An ordinary response gains the headers only when its handler set no +/// `Access-Control-Allow-Origin` of its own. +final class Cors { + static final String ALLOWED_ORIGINS = "cn1.cors.allowedOrigins"; + static final String ALLOWED_METHODS = "cn1.cors.allowedMethods"; + static final String ALLOWED_HEADERS = "cn1.cors.allowedHeaders"; + static final String EXPOSED_HEADERS = "cn1.cors.exposedHeaders"; + static final String ALLOW_CREDENTIALS = "cn1.cors.allowCredentials"; + static final String MAX_AGE = "cn1.cors.maxAgeSeconds"; + + private final List origins; + private final boolean anyOrigin; + private final String methods; + private final String headers; + private final String exposed; + private final boolean credentials; + private final int maxAge; + + private Cors(List origins, String methods, String headers, String exposed, + boolean credentials, int maxAge) { + this.origins = origins; + this.anyOrigin = origins.contains("*"); + this.methods = methods; + this.headers = headers; + this.exposed = exposed; + this.credentials = credentials; + this.maxAge = maxAge; + } + + /// Whether the server speaks TLS; set once, before it serves anything. + private boolean tls; + + /// Records whether requests arrive over TLS, which decides the scheme of the + /// server's own origin. + void servedOverTls(boolean value) { + this.tls = value; + } + + /// The configured policy, or null when CORS is off. + static Cors fromConfig(Config config) throws IOException { + String listed = config.get(ALLOWED_ORIGINS, null); + if (listed == null || listed.trim().length() == 0) { + return null; + } + List origins = new ArrayList(); + int pos = 0; + while (pos <= listed.length()) { + int comma = listed.indexOf(',', pos); + int end = comma < 0 ? listed.length() : comma; + String origin = listed.substring(pos, end).trim(); + if (origin.length() > 0) { + // An origin is scheme://host[:port] with nothing after it; a path, + // query or fragment (a trailing slash included) never matches what a + // browser sends, so it is refused here rather than leaving a policy + // that silently allows no one. + if (!"*".equals(origin) && !isOrigin(origin)) { + throw new IOException(ALLOWED_ORIGINS + " names \"" + origin + "\"; an origin " + + "is scheme://host[:port], with no path"); + } + origins.add(origin); + } + if (comma < 0) { + break; + } + pos = comma + 1; + } + boolean credentials = config.getBoolean(ALLOW_CREDENTIALS, false); + if (credentials && origins.contains("*")) { + // Browsers refuse a credentialed response that allows every origin, so + // the combination is a policy that cannot work as written. + throw new IOException(ALLOW_CREDENTIALS + "=true needs " + ALLOWED_ORIGINS + + " to list the origins; a browser refuses credentials with *"); + } + return new Cors(origins, + config.get(ALLOWED_METHODS, "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"), + config.get(ALLOWED_HEADERS, "*"), + config.get(EXPOSED_HEADERS, ""), + credentials, + config.getInt(MAX_AGE, 1800)); + } + + /// Whether `origin` may read responses. + boolean allows(String origin) { + if (origin == null) { + return false; + } + if (anyOrigin) { + return true; + } + for (Object listed : origins) { + if (origin.equalsIgnoreCase((String) listed)) { + return true; + } + } + return false; + } + + /// A cross-origin request the policy does not admit -- its origin is not + /// listed, or its method (the requested one, for a preflight) is not in + /// `cn1.cors.allowedMethods` -- answered 403 before any handler runs, as + /// Spring's CorsProcessor answers it; null otherwise. Checked only after the + /// response used to be: a simple request (a `text/plain` POST needs no + /// preflight) then ran its handler, side effects and all, and only the CORS + /// headers were withheld -- and the method list was never consulted for it. + /// A same-origin request carries an Origin too, and is not a CORS request. + HttpServer.Response reject(HttpServer.Request request) { + String origin = request.getHeader("Origin"); + if (origin == null || sameOrigin(origin, requestScheme(request), request.getHeader("Host"))) { + return null; + } + String asked = request.getHeader("Access-Control-Request-Method"); + String method = "OPTIONS".equals(request.getMethod()) && asked != null ? asked.trim() + : request.getMethod(); + if (!allows(origin)) { + return varyByOrigin(HttpServer.Response.text(403, "origin not allowed")); + } + if (!allowsMethod(method)) { + return varyByOrigin(HttpServer.Response.text(403, "method not allowed")); + } + return null; + } + + private boolean allowsMethod(String method) { + if ("*".equals(methods.trim())) { + return true; + } + int pos = 0; + while (pos <= methods.length()) { + int comma = methods.indexOf(',', pos); + int end = comma < 0 ? methods.length() : comma; + if (methods.substring(pos, end).trim().equals(method)) { + return true; + } + if (comma < 0) { + break; + } + pos = comma + 1; + } + return false; + } + + /// The scheme the request was sent with: X-Forwarded-Proto when a TLS + /// terminating proxy names it -- the server itself then sees plain HTTP for a + /// page served over https -- else whether this server speaks TLS. A page + /// cannot put that header on a simple request, and a preflight carries only + /// the names of the headers to come. + private String requestScheme(HttpServer.Request request) { + String forwarded = request.getHeader("X-Forwarded-Proto"); + if (forwarded != null) { + int comma = forwarded.indexOf(','); + String first = (comma < 0 ? forwarded : forwarded.substring(0, comma)).trim(); + if (first.length() > 0) { + return first; + } + } + return tls ? "https" : "http"; + } + + /// Whether `origin` names the server the request was sent to: the same scheme, + /// and a host and port equal to the Host header's, a default port written or + /// not. Without the scheme, `http://api.example` calling the TLS server at + /// `https://api.example` -- a cross-origin request to a browser -- skipped + /// the policy altogether. + private static boolean sameOrigin(String origin, String requestScheme, String host) { + if (host == null) { + return false; + } + int scheme = origin.indexOf("://"); + if (scheme < 0 || !origin.substring(0, scheme).equalsIgnoreCase(requestScheme)) { + return false; + } + String authority = origin.substring(scheme + 3); + boolean https = "https".equalsIgnoreCase(requestScheme); + return withoutDefaultPort(authority, https).equalsIgnoreCase(withoutDefaultPort(host.trim(), https)); + } + + private static String withoutDefaultPort(String authority, boolean https) { + String port = https ? ":443" : ":80"; + return authority.endsWith(port) ? authority.substring(0, authority.length() - port.length()) + : authority; + } + + /// The answer to a preflight no handler took, or null when `request` is not + /// one. A preflight from an origin the policy does not list is a 403. + HttpServer.Response preflight(HttpServer.Request request) { + if (!"OPTIONS".equals(request.getMethod())) { + return null; + } + String origin = request.getHeader("Origin"); + String asked = request.getHeader("Access-Control-Request-Method"); + if (origin == null || asked == null) { + return null; + } + if (!allows(origin)) { + return varyByOrigin(HttpServer.Response.text(403, "origin not allowed")); + } + HttpServer.Response response = varyByOrigin(HttpServer.Response.empty(204, null, null)); + allowOrigin(response, origin); + response.header("Access-Control-Allow-Methods", methods); + String requested = request.getHeader("Access-Control-Request-Headers"); + if ("*".equals(headers.trim())) { + // With credentials "*" is a literal header name to a browser, so the + // headers actually requested are echoed instead; without them it is a + // wildcard and echoing is equivalent. + if (requested != null && requested.length() > 0) { + response.header("Access-Control-Allow-Headers", requested); + } + } else { + response.header("Access-Control-Allow-Headers", headers); + } + response.header("Access-Control-Max-Age", String.valueOf(maxAge)); + return response; + } + + /// Adds the headers a browser reads to a response for an allowed origin. + void decorate(HttpServer.Request request, HttpServer.Response response) { + if (response == null || response.hasHeader("Access-Control-Allow-Origin")) { + return; + } + if (!anyOrigin || credentials) { + // The answer depends on Origin whenever the policy names origins -- the + // headers below or their absence -- so a shared cache must key on it even + // for a request with no Origin or a denied one. Without it, a cached copy + // with no CORS headers was served to an allowed origin, whose browser + // then blocked a permitted response. + response.appendToken("Vary", "Origin"); + } + String origin = request.getHeader("Origin"); + if (origin == null || !allows(origin)) { + return; + } + allowOrigin(response, origin); + if (exposed.trim().length() > 0) { + response.header("Access-Control-Expose-Headers", exposed); + } + } + + /// `response`, with `Vary: Origin` when the policy names origins. + private HttpServer.Response varyByOrigin(HttpServer.Response response) { + if (!anyOrigin || credentials) { + response.appendToken("Vary", "Origin"); + } + return response; + } + + private void allowOrigin(HttpServer.Response response, String origin) { + // The origin itself rather than "*" whenever the answer depends on it, and + // Vary so a cache does not hand one origin's answer to another. + if (anyOrigin && !credentials) { + response.header("Access-Control-Allow-Origin", "*"); + } else { + // Vary: Origin was added by decorate, for every answer of this policy. + response.header("Access-Control-Allow-Origin", origin); + } + if (credentials) { + response.header("Access-Control-Allow-Credentials", "true"); + } + } + + /// Whether `value` is scheme://authority and nothing more. + private static boolean isOrigin(String value) { + int scheme = value.indexOf("://"); + if (scheme <= 0) { + return false; + } + String authority = value.substring(scheme + 3); + return authority.length() > 0 && authority.indexOf('/') < 0 && authority.indexOf('?') < 0 + && authority.indexOf('#') < 0; + } +} diff --git a/vm/backend/src/com/codename1/backend/DataSource.java b/vm/backend/src/com/codename1/backend/DataSource.java index 3629096fe74..ac57cc1662e 100644 --- a/vm/backend/src/com/codename1/backend/DataSource.java +++ b/vm/backend/src/com/codename1/backend/DataSource.java @@ -22,6 +22,8 @@ */ package com.codename1.backend; +import com.codename1.impl.backend.AsyncTask; + import java.io.IOException; import java.util.ArrayList; import java.util.List; diff --git a/vm/backend/src/com/codename1/backend/HttpServer.java b/vm/backend/src/com/codename1/backend/HttpServer.java index 771d5d83830..f89b02c5c33 100644 --- a/vm/backend/src/com/codename1/backend/HttpServer.java +++ b/vm/backend/src/com/codename1/backend/HttpServer.java @@ -80,6 +80,14 @@ public static final class Request { private String target; private String version; private String body; + /// A body whose Content-Type is not text, as it arrived (after any + /// Content-Encoding); null for a text body, which [#body] holds. See + /// [#getBodyBytes]. + private byte[] bodyBytes; + /// [#getBodyBytes] of a text body, encoded once on first ask. + private byte[] encodedBody; + /// The parts of a multipart body, split on first ask; see [#getParts]. + private List parts; /// The connection this request arrived on; null for HTTP/2, see respond. private Conn conn; /// The bytes the header block was read from. @@ -126,7 +134,7 @@ public static final class Request { /// This request's session once looked up; see [#getSession(boolean)]. private HttpSession session; private boolean sessionResolved; - /// The request's `@RequestScope` beans, by the slot the build gave each. + /// The request's request-scoped beans, by the slot the build gave each. private Object[] scopedBeans; Request(String method, String target, String version, byte[] raw, int[] slices, @@ -554,6 +562,9 @@ void releaseRetained() { this.raw = null; this.slices = null; this.body = null; + this.bodyBytes = null; + this.encodedBody = null; + this.parts = null; this.headers = null; this.session = null; this.sessionResolved = false; @@ -572,7 +583,7 @@ public HttpSession getSession() { /// The session this request belongs to: the one its cookie names, or, with /// `create` set and no valid cookie, a new one the response will /// send a cookie for. Null when there is none and `create` is false. - /// See [Sessions] for the cookie and where sessions are kept. + /// See [HttpSession] for the cookie and where sessions are kept. public HttpSession getSession(boolean create) { if (sessionResolved && session != null && !session.isValid()) { // Invalidated earlier in this request: it is not the session any @@ -631,9 +642,9 @@ List endedSessions() { return endedSessions; } - /// This request's `@RequestScope` beans, grown to hold at least + /// This request's request-scoped beans, grown to hold at least /// `count`. Called by generated code. - public Object[] scopedBeans(int count) { + Object[] scopedBeans(int count) { if (scopedBeans == null || scopedBeans.length < count) { Object[] grown = new Object[count]; if (scopedBeans != null) { @@ -658,6 +669,14 @@ void setBody(String value) { this.body = value; } + /// The body as [#bodyContent] decided it: text, or bytes, never both. + void setBody(String text, byte[] binary) { + this.body = text; + this.bodyBytes = binary; + this.encodedBody = null; + this.parts = null; + } + void reset(Conn conn, String method, String target, String version, byte[] raw, int[] slices, int headerCount, String body, int targetStart, int targetLength) { @@ -669,6 +688,9 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, this.slices = slices; this.headerCount = headerCount; this.body = body; + this.bodyBytes = null; + this.encodedBody = null; + this.parts = null; this.headers = null; this.targetStart = targetStart; this.targetLength = targetLength; @@ -690,6 +712,13 @@ void reset(Conn conn, String method, String target, String version, byte[] raw, /// For HTTP/2, whose headers arrive already decoded from the HPACK state -- /// there is no request buffer to slice into, so the map IS the /// representation and every lookup below falls back to it. + /// The same, for a body [#bodyContent] has already split into text or bytes. + Request(String method, String target, String version, Map headers, String body, + byte[] binary) { + this(method, target, version, headers, body); + this.bodyBytes = binary; + } + Request(String method, String target, String version, Map headers, String body) { // pathLength is NOT set here, and does not need to be: it carries a // field initializer (= -1) and javac copies those into every @@ -933,9 +962,184 @@ int countHeader(String name) { return found; } + /// The body as text, or null when the request has none. + /// + /// A text body -- any Content-Type that is not binary, or none -- was + /// checked to be UTF-8 when it arrived, and a request whose body was not is + /// answered 400 before any handler runs. A binary body (an upload, a + /// `multipart/form-data` form) is meant for [#getBodyBytes]. Asked for as + /// text anyway, it is decoded when its bytes are UTF-8 -- as Spring's string + /// converter reads any media type into a `@RequestBody String` -- and throws + /// when they are not, rather than handing a handler characters the client + /// never sent. public String getBody() { + if (body == null && bodyBytes != null) { + if (!Utf8.isValid(bodyBytes, 0, bodyBytes.length)) { + throw new IllegalStateException("The request body is binary (" + + getHeader("Content-Type") + "); read it with getBodyBytes()"); + } + try { + body = new String(bodyBytes, "UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString(), err); + } + } return body; } + + /// The body as bytes, or null when the request has none: a binary body as + /// it arrived -- decompressed when the client sent it gzip encoded -- and a + /// text body as its UTF-8. The array is the request's; copy it to keep it + /// beyond the handler. + public byte[] getBodyBytes() { + if (bodyBytes != null) { + return bodyBytes; + } + if (body == null) { + return null; + } + if (encodedBody == null) { + encodedBody = Response.bytes(body); + } + return encodedBody; + } + + /// The parts of a `multipart/form-data` body, in order; an empty list + /// when the body is not one. Throws IllegalArgumentException for a body + /// that says it is multipart and is not well formed, which a generated + /// router answers with a 400. + public List getParts() { + if (parts == null) { + String type = getHeader("Content-Type"); + String boundary = Multipart.boundary(type); + if (boundary == null && Multipart.isMultipart(type)) { + // Declared multipart with no usable boundary: malformed, and + // refused as such. Read as "not multipart", a route whose + // @RequestPart values are optional ran on a body nothing parsed. + throw new IllegalArgumentException("the multipart/form-data body names no " + + "valid boundary"); + } + byte[] data = boundary == null ? null : getBodyBytes(); + if (boundary != null && (data == null || data.length == 0)) { + // Even a form with no fields carries its closing delimiter, so + // a multipart body with no bytes at all is malformed -- not a + // request with no parts for an optional binding to run on. + throw new IllegalArgumentException("the multipart/form-data body is empty"); + } + parts = data == null ? new ArrayList() : Multipart.parse(data, boundary); + } + return parts; + } + + /// The first part named `name`, or null. + public Part getPart(String name) { + for (Object each : getParts()) { + Part p = (Part) each; + if (name != null && name.equals(p.getName())) { + return p; + } + } + return null; + } + + /// A request parameter, as `@RequestParam` reads it and as a servlet + /// container does: the query string first, then the fields of a form body + /// -- `application/x-www-form-urlencoded`, or the parts of a + /// `multipart/form-data` one that are not files. Null when neither has it. + /// + /// @throws IllegalArgumentException when the body says it is multipart and + /// is not well formed, as [#getParts] does; read as "no such field", a + /// route whose parameters are all optional ran on a body nothing parsed + /// @throws IllegalStateException when the field is a multipart part that is + /// not UTF-8 text. A generated `@RequestParam` binding answers either with 400 + public String param(String name) { + String query = queryParam(name); + if (query != null || name == null) { + return query; + } + String type = getHeader("Content-Type"); + if (Multipart.isForm(type)) { + return Multipart.formValue(getBody(), name); + } + if (Multipart.isMultipart(type)) { + Part p = getPart(name); + return p == null || p.getFilename() != null ? null : p.getText(); + } + return null; + } + } + + /// One part of a `multipart/form-data` request body: a form field, or an + /// uploaded file when it has a filename. + public static final class Part { + private final String name; + private final String filename; + private final String contentType; + private final Map headers; + private final byte[] data; + + Part(String name, String filename, String contentType, Map headers, byte[] data) { + this.name = name; + this.filename = filename; + this.contentType = contentType; + this.headers = headers; + this.data = data; + } + + /// The field name from its Content-Disposition, or null. + public String getName() { + return name; + } + + /// The uploaded file's name as the client gave it, or null for a plain + /// field. Never use it as a path: it is whatever the client sent. + public String getFilename() { + return filename; + } + + /// The part's Content-Type, or null when it declared none. + public String getContentType() { + return contentType; + } + + /// One of the part's own headers, by a name in any case; or null. + public String getHeader(String headerName) { + if (headerName == null) { + return null; + } + java.util.Iterator it = headers.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (headerName.equalsIgnoreCase((String) e.getKey())) { + return (String) e.getValue(); + } + } + return null; + } + + /// The part's content length in bytes. + public int getSize() { + return data.length; + } + + /// The part's content. The array is the part's; copy it to change it. + public byte[] getBytes() { + return data; + } + + /// The part's content as UTF-8 text. Throws IllegalStateException when it + /// is not valid UTF-8 -- a binary upload -- rather than returning + /// replacement characters the client never sent. + public String getText() { + if (!Utf8.isValid(data, 0, data.length)) { + throw new IllegalStateException("Part \"" + name + "\" is not UTF-8 text"); + } + try { + return new String(data, "UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString(), err); + } + } } /// What a handler returns. @@ -1104,6 +1308,80 @@ public Response header(String name, String value) { return this; } + /// Whether a header of this name, in any case, has been set. + boolean hasHeader(String name) { + return headerValue(name) != null; + } + + /// The value set for a header of this name, in any case, or null. + String headerValue(String name) { + if (extraHeaders == null) { + return null; + } + java.util.Iterator it = extraHeaders.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (name.equalsIgnoreCase(String.valueOf(e.getKey())) && e.getValue() != null) { + return String.valueOf(e.getValue()); + } + } + return null; + } + + /// Adds `token` to a comma-separated header such as Vary, keeping what is + /// there, and setting it under the name it already has. A value the + /// handler gave as a List is joined element by element -- its toString() + /// would send "[Origin, Cookie]", brackets a cache cannot read as field + /// names -- and a token already listed is not added twice. + void appendToken(String name, String token) { + String key = name; + StringBuilder existing = new StringBuilder(); + if (extraHeaders != null) { + java.util.Iterator it = extraHeaders.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + if (name.equalsIgnoreCase(String.valueOf(e.getKey()))) { + key = String.valueOf(e.getKey()); + Object value = e.getValue(); + if (value instanceof List) { + for (Object item : (List) value) { + joinToken(existing, item); + } + } else { + joinToken(existing, value); + } + } + } + } + String current = existing.toString(); + int start = 0; + while (start <= current.length()) { + int comma = current.indexOf(',', start); + int end = comma < 0 ? current.length() : comma; + if (current.substring(start, end).trim().equalsIgnoreCase(token)) { + header(key, current); + return; + } + if (comma < 0) { + break; + } + start = comma + 1; + } + joinToken(existing, token); + header(key, existing.toString()); + } + + private static void joinToken(StringBuilder out, Object value) { + String text = value == null ? "" : String.valueOf(value).trim(); + if (text.length() == 0) { + return; + } + if (out.length() > 0) { + out.append(", "); + } + out.append(text); + } + private static byte[] bytes(String s) { try { return s == null ? new byte[0] : s.getBytes("UTF-8"); @@ -4678,6 +4956,12 @@ private void serve(int fd) { } /// A malformed request that deserves a specific status before the close. + /// The status a refusal from [#bodyContent] carries, or 400 for any other + /// failure; for code outside this class, which cannot name ProtocolException. + static int refusalStatus(IOException refused) { + return refused instanceof ProtocolException ? ((ProtocolException) refused).status : 400; + } + private static final class ProtocolException extends IOException { final int status; @@ -4685,6 +4969,12 @@ private static final class ProtocolException extends IOException { super(message); this.status = status; } + + ProtocolException(int status, String message, Throwable cause) { + super(message); + initCause(cause); + this.status = status; + } } /// A connection plus whatever has been read from it and not yet consumed. @@ -5869,24 +6159,37 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) continue; } byte[] h2RequestBody = stream.getBody(); - if (h2RequestBody != null && h2RequestBody.length > 0 - && !Utf8.isValid(h2RequestBody, 0, h2RequestBody.length)) { - // Decided here rather than in getBodyAsString, because this is - // where a status code can be produced: the decoder has no way - // to answer 400, and returning null there would have made a - // malformed body indistinguishable from an absent one. - if (!h2.respond(stream.getId(), 400, "text/plain", new ArrayList(), - asciiBytes("the request body is not valid UTF-8"))) { - // The explanation is itself a body, and under a full - // process budget respond() takes nothing and says so. This - // path ignored that and moved on, so the stream was left - // unanswered until the connection timed out -- a client - // that sent bad bytes under load simply hung. The status - // still has to arrive; only the sentence is optional. - h2.respond(stream.getId(), 400, "text/plain", new ArrayList(), null); + // The body read through the same decision HTTP/1 makes -- gzip, + // then text or bytes by Content-Type -- so a handler cannot tell + // which protocol carried it. Decided here rather than in + // getBodyAsString, because this is where a status code can be + // produced: the decoder has no way to answer 400, and returning + // null there would have made a malformed body indistinguishable + // from an absent one. + String h2Text = null; + byte[] h2Binary = null; + if (h2RequestBody != null && h2RequestBody.length > 0) { + Object[] content; + try { + content = bodyContent(new Request(stream.getMethod(), stream.getPath(), + "HTTP/2", headers, null), h2RequestBody, 0, h2RequestBody.length); + } catch (ProtocolException refused) { + if (!h2.respond(stream.getId(), refused.status, "text/plain", + new ArrayList(), asciiBytes(refused.getMessage()))) { + // The explanation is itself a body, and under a full + // process budget respond() takes nothing and says so. + // Ignoring that left the stream unanswered until the + // connection timed out -- a client that sent bad bytes + // under load simply hung. The status still has to + // arrive; only the sentence is optional. + h2.respond(stream.getId(), refused.status, "text/plain", + new ArrayList(), null); + } + requestsServed.incrementAndGet(); + continue; } - requestsServed.incrementAndGet(); - continue; + h2Text = (String) content[0]; + h2Binary = (byte[]) content[1]; } if (!targetDecodesToUtf8(stream.getPath())) { // The same rule the HTTP/1 request line takes, because the @@ -5933,7 +6236,7 @@ private void serveHttp2(int fd, long session, byte[] pending, int pendingLength) continue; } Request request = new Request(stream.getMethod(), stream.getPath(), - "HTTP/2", headers, stream.getBodyAsString()); + "HTTP/2", headers, h2Text, h2Binary); Response response; inFlightRequests.incrementAndGet(); SERVING_FD.set(Integer.valueOf(fd)); @@ -7482,15 +7785,17 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { } String body = null; + byte[] binary = null; if (chunked) { byte[] decoded = readChunked(conn, scratch); if (decoded == null) { return null; } - if (decoded.length > 0 && !Utf8.isValid(decoded, 0, decoded.length)) { - throw new ProtocolException(400, "the request body is not valid UTF-8"); + if (decoded.length > 0) { + Object[] content = bodyContent(request, decoded, 0, decoded.length); + body = (String) content[0]; + binary = (byte[]) content[1]; } - body = decoded.length == 0 ? null : new String(decoded, "UTF-8"); } else if (contentLength != null) { // Already refused above if it was malformed or too large, which has to // happen before an Expect is answered rather than here. @@ -7498,18 +7803,9 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { return null; } if (declaredLength > 0) { - // Checked before it is decoded. new String replaces a malformed - // sequence with U+FFFD rather than failing, so without this the - // handler is handed text the client never sent -- and whatever - // validated it validated the replacement. Note the query-string - // decoder above does the same thing with percent-decoded bytes; - // that one is left alone deliberately, because refusing a query - // parameter is a different policy from refusing a body, and no - // report has been made against it. - if (!Utf8.isValid(conn.buffer, conn.pos, declaredLength)) { - throw new ProtocolException(400, "the request body is not valid UTF-8"); - } - body = new String(conn.buffer, conn.pos, declaredLength, "UTF-8"); + Object[] content = bodyContent(request, conn.buffer, conn.pos, declaredLength); + body = (String) content[0]; + binary = (byte[]) content[1]; conn.pos += declaredLength; } } @@ -7518,7 +7814,7 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { // to a handler -- so neither one mutates anything a handler can see, and // "immutable to its handler" is preserved either way. The slices and the // array are shared, not copied. - if (body == null) { + if (body == null && binary == null) { return request; } // THE BODY ONLY. This used to re-point the whole request -- every field @@ -7535,10 +7831,149 @@ private Request readRequest(Conn conn, byte[] scratch) throws IOException { // getHeader answered null against a request whose own head was still // perfectly intact 82 bytes away. Nothing else changed between the parse // and here, so nothing else needs assigning. - request.setBody(body); + request.setBody(body, binary); return request; } + /// What a request body becomes for its handler: `{text, null}` or + /// `{null, bytes}`. + /// + /// A `Content-Encoding: gzip` body is inflated first, to at most + /// MAX_BODY_BYTES -- the limit a plain body has, so compression cannot carry + /// a larger one past it. Any other coding is a 415: decoding it is the + /// server's job, and handing a handler bytes it cannot read is not. + /// + /// Then the Content-Type decides. A text body -- `text/*`, JSON, XML, a + /// urlencoded form, or no type at all -- is checked to be UTF-8 before it is + /// decoded: new String replaces a malformed sequence with U+FFFD rather than + /// failing, so without the check the handler is handed text the client never + /// sent, and whatever validated it validated the replacement. Anything else + /// is binary -- an image, `application/octet-stream`, a + /// `multipart/form-data` upload -- and is kept as bytes, unchecked, for + /// getBodyBytes(). Note the query-string decoder does the same thing with + /// percent-decoded bytes; that one is left alone deliberately, because + /// refusing a query parameter is a different policy from refusing a body. + /// + /// Shared by HTTP/1.1 and HTTP/2, so both protocols give a handler the same + /// body for the same request. + static Object[] bodyContent(Request request, byte[] data, int offset, int length) + throws ProtocolException { + // The listener refuses an oversized body from its Content-Length before + // reading it; checked here as well so every other way in -- MockMvc's + // in-process dispatch -- refuses what production refuses. + if (length > MAX_BODY_BYTES) { + throw new ProtocolException(413, "request body too large"); + } + String coding = request.getHeader("Content-Encoding"); + if (coding != null) { + coding = coding.trim(); + if ("gzip".equalsIgnoreCase(coding) || "x-gzip".equalsIgnoreCase(coding)) { + data = gunzip(data, offset, length); + offset = 0; + length = data.length; + } else if (coding.length() > 0 && !"identity".equalsIgnoreCase(coding)) { + throw new ProtocolException(415, "unsupported Content-Encoding"); + } + } + if (isBinaryType(request.getHeader("Content-Type"))) { + byte[] copy = new byte[length]; + System.arraycopy(data, offset, copy, 0, length); + return new Object[] {null, copy}; + } + if (!Utf8.isValid(data, offset, length)) { + throw new ProtocolException(400, "the request body is not valid UTF-8"); + } + try { + return new Object[] {new String(data, offset, length, "UTF-8"), null}; + } catch (java.io.UnsupportedEncodingException err) { + throw new ProtocolException(500, err.toString(), err); + } + } + + /// Whether a Content-Type names a body that is not text. Text is `text/*`, + /// JSON and XML (with their `+json`/`+xml` suffixes), a urlencoded form, + /// JavaScript and GraphQL; a missing type is text too, which is what every + /// body was before binary ones were kept. Compared without case folding, + /// since a media type is ASCII by specification and toLowerCase() is not. + static boolean isBinaryType(String contentType) { + if (contentType == null) { + return false; + } + int semi = contentType.indexOf(';'); + String type = (semi < 0 ? contentType : contentType.substring(0, semi)).trim(); + if (type.length() == 0 || type.regionMatches(true, 0, "text/", 0, 5)) { + return false; + } + if ("application/json".equalsIgnoreCase(type) + || "application/xml".equalsIgnoreCase(type) + || "application/x-www-form-urlencoded".equalsIgnoreCase(type) + || "application/javascript".equalsIgnoreCase(type) + || "application/graphql".equalsIgnoreCase(type)) { + return false; + } + return !(endsWithIgnoreCase(type, "+json") || endsWithIgnoreCase(type, "+xml")); + } + + private static boolean endsWithIgnoreCase(String value, String suffix) { + return value.length() >= suffix.length() + && value.regionMatches(true, value.length() - suffix.length(), suffix, 0, + suffix.length()); + } + + /// Inflates a gzip body, refusing one that grows past MAX_BODY_BYTES (413) or + /// is not gzip at all (400). + /// + /// Every member is decoded: a gzip body may be several members back to back + /// (RFC 1952 2.2), and stopping at the end of the first handed the handler a + /// silently truncated payload. Bytes after a member that are not another + /// member are refused rather than ignored. + static byte[] gunzip(byte[] data, int offset, int length) throws ProtocolException { + ByteSink out = new ByteSink(Math.max(256, Math.min(length * 4, MAX_BODY_BYTES))); + byte[] chunk = new byte[8192]; + int pos = offset; + int end = offset + length; + do { + java.io.ByteArrayInputStream source = new java.io.ByteArrayInputStream(data, pos, end - pos); + com.codename1.io.gzip.GZIPInputStream in = null; + int unread; + try { + in = new com.codename1.io.gzip.GZIPInputStream(source); + while (true) { + int n = in.read(chunk, 0, chunk.length); + if (n < 0) { + break; + } + if (out.length() + n > MAX_BODY_BYTES) { + throw new ProtocolException(413, "request body too large once decompressed"); + } + out.put(chunk, 0, n); + } + // What this member did not use: still in the source, or read + // ahead into the inflater and left over at the member's end. + byte[] left = in.getAvailIn(); + unread = source.available() + (left == null ? 0 : left.length); + } catch (ProtocolException err) { + throw err; + } catch (IOException err) { + throw new ProtocolException(400, "the request body is not valid gzip", err); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ignored) { + // In memory; nothing to release. + } + } + } + int next = end - unread; + if (next <= pos) { + throw new ProtocolException(400, "the request body is not valid gzip"); + } + pos = next; + } while (pos < end); + return usedBytes(out); + } + /// Decodes a chunked body: a size in hex, CRLF, that many bytes, CRLF, until a /// zero-length chunk. The trailer section after it is consumed and discarded -- /// ignoring trailers is allowed, but leaving them in the stream would diff --git a/vm/backend/src/com/codename1/backend/HttpSession.java b/vm/backend/src/com/codename1/backend/HttpSession.java index c2f89837549..f5a51e09de4 100644 --- a/vm/backend/src/com/codename1/backend/HttpSession.java +++ b/vm/backend/src/com/codename1/backend/HttpSession.java @@ -43,14 +43,14 @@ /// `getSession(true)`, so a server that never does sets no cookie and keeps /// nothing. The cookie is `HttpOnly`, `SameSite=Lax` and, on a TLS /// server, `Secure`; its name, lifetime and store are configured under -/// `cn1.session.*`. See [Sessions]. +/// `cn1.session.*`. /// /// Attributes live in the [SessionStore]. The in-memory store keeps any /// object; the database store keeps what [Json] can write -- strings, /// numbers, booleans, and maps and lists of those -- because it has to read them /// back in another process. /// -/// A `@SessionScope` bean is never written to a store: the server that +/// A session-scoped bean is never written to a store: the server that /// built it keeps it in memory for as long as the session lives, and runs its /// destroy methods when the session is invalidated, expires or the server stops. /// Another instance behind a load balancer builds its own. @@ -148,7 +148,7 @@ public synchronized void invalidate() { checkValid(); invalid = true; attributes.clear(); - // The @SessionScope beans stay until the request ends, when the server + // The session-scoped beans stay until the request ends, when the server // runs their destroy methods; dropping them here would skip those. dirty = true; } @@ -304,14 +304,14 @@ synchronized Object[] takeLocalBeans() { /// What generated code locks while it builds one of this session's beans: an /// object every loaded copy of the session shares. - public Object beanLock() { + Object beanLock() { Sessions o = owner; return o == null ? this : o.beanLock(this); } - /// The `@SessionScope` beans of this session, by the slot the build gave + /// The session-scoped beans of this session, by the slot the build gave /// each. Called by generated code. - public Object[] scopedBeans(int count) { + Object[] scopedBeans(int count) { Sessions o = owner; if (o != null) { return o.sharedBeans(this, count); diff --git a/vm/backend/src/com/codename1/backend/Multipart.java b/vm/backend/src/com/codename1/backend/Multipart.java new file mode 100644 index 00000000000..bf85d33a437 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Multipart.java @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Splits a `multipart/form-data` body (RFC 7578) into [HttpServer.Part]s, and +/// decodes an `application/x-www-form-urlencoded` one. +/// +/// Bounded the way the header parser is: a body of a thousand empty parts, or one +/// part whose header block never ends, is refused rather than walked. Refusals are +/// IllegalArgumentException, which the generated routers answer with a 400; a +/// handler calling getParts() itself sees the same exception. +final class Multipart { + /// Parts in one body. A form is a few fields and a file or two. + static final int MAX_PARTS = 1000; + /// The header block of one part: Content-Disposition and Content-Type. + static final int MAX_PART_HEADER_BYTES = 8192; + + private Multipart() { + } + + /// The boundary a multipart Content-Type names, or null when it is not + /// `multipart/form-data` or names none. + static String boundary(String contentType) { + if (contentType == null) { + return null; + } + int semi = contentType.indexOf(';'); + String type = (semi < 0 ? contentType : contentType.substring(0, semi)).trim(); + if (!"multipart/form-data".equalsIgnoreCase(type) || semi < 0) { + return null; + } + String value = parameter(contentType.substring(semi + 1), "boundary"); + return value == null || value.length() == 0 || value.length() > 70 ? null : value; + } + + /// Whether a Content-Type declares `multipart/form-data`, whatever its + /// boundary -- so a declaration with a missing or invalid one can be told + /// from a body that is not multipart at all. + static boolean isMultipart(String contentType) { + if (contentType == null) { + return false; + } + int semi = contentType.indexOf(';'); + String type = (semi < 0 ? contentType : contentType.substring(0, semi)).trim(); + return "multipart/form-data".equalsIgnoreCase(type); + } + + /// Whether a Content-Type is a urlencoded form. + static boolean isForm(String contentType) { + if (contentType == null) { + return false; + } + int semi = contentType.indexOf(';'); + String type = (semi < 0 ? contentType : contentType.substring(0, semi)).trim(); + return "application/x-www-form-urlencoded".equalsIgnoreCase(type); + } + + /// The parts of `body`, in order. + static List parse(byte[] body, String boundary) { + byte[] delimiter = ascii("--" + boundary); + List parts = new ArrayList(); + int pos = indexOf(body, delimiter, 0); + if (pos < 0) { + throw new IllegalArgumentException("the multipart body has no boundary"); + } + while (true) { + pos += delimiter.length; + if (pos + 1 < body.length && body[pos] == '-' && body[pos + 1] == '-') { + if (!closeEnds(body, pos + 2)) { + throw new IllegalArgumentException("the closing multipart boundary is " + + "followed by more than padding and CRLF"); + } + return parts; + } + // Transport padding (RFC 2046) is allowed between a boundary and its CRLF. + while (pos < body.length && (body[pos] == ' ' || body[pos] == '\t')) { + pos++; + } + if (!crlfAt(body, pos)) { + throw new IllegalArgumentException("a multipart boundary is not followed by CRLF"); + } + pos += 2; + if (parts.size() == MAX_PARTS) { + throw new IllegalArgumentException("the multipart body has more than " + + MAX_PARTS + " parts"); + } + Map headers = new LinkedHashMap(); + // The limit is on the whole header block, counted from its start. Per + // line, it let one part carry hundreds of thousands of short headers -- + // each line parsed and stored -- inside the body limit. + int headersStart = pos; + while (!crlfAt(body, pos)) { + int end = indexOfCrLf(body, pos); + if (end < 0 || end - headersStart > MAX_PART_HEADER_BYTES) { + throw new IllegalArgumentException("a multipart part's headers never end"); + } + String line = latin1(body, pos, end - pos); + int colon = line.indexOf(':'); + if (colon <= 0) { + throw new IllegalArgumentException("a multipart part has a malformed header"); + } + headers.put(asciiLower(line.substring(0, colon).trim()), + line.substring(colon + 1).trim()); + pos = end + 2; + } + pos += 2; + byte[] closing = new byte[delimiter.length + 2]; + closing[0] = '\r'; + closing[1] = '\n'; + System.arraycopy(delimiter, 0, closing, 2, delimiter.length); + int next = closingDelimiter(body, closing, pos); + if (next < 0) { + throw new IllegalArgumentException("a multipart part is never closed"); + } + byte[] data = new byte[next - pos]; + System.arraycopy(body, pos, data, 0, data.length); + String disposition = (String) headers.get("content-disposition"); + String name = disposition == null ? null : parameter(afterType(disposition), "name"); + String filename = disposition == null ? null + : parameter(afterType(disposition), "filename"); + parts.add(new HttpServer.Part(name, filename, (String) headers.get("content-type"), + headers, data)); + pos = next + 2; + } + } + + /// Where the delimiter that closes a part starts, searching from `from`, or -1. + /// A delimiter is complete only when `--` follows it (the last one) or + /// optional transport padding and CRLF (RFC 2046 5.1.1). A part's own bytes + /// may contain `CRLF--boundary` followed by anything else -- a binary file + /// can -- and taking that for the end of the part refused a legal upload. + private static int closingDelimiter(byte[] body, byte[] closing, int from) { + int at = indexOf(body, closing, from); + while (at >= 0) { + int after = at + closing.length; + if (after + 1 < body.length && body[after] == '-' && body[after + 1] == '-' + && closeEnds(body, after + 2)) { + return at; + } + int p = after; + while (p < body.length && (body[p] == ' ' || body[p] == '\t')) { + p++; + } + if (crlfAt(body, p)) { + return at; + } + at = indexOf(body, closing, at + 1); + } + return -1; + } + + /// Whether the close delimiter whose `--` ends just before `pos` really ends + /// there: transport padding, then the end of the body or the CRLF that starts + /// the epilogue (RFC 2046 5.1.1). `--b--garbage` is not a close delimiter. + private static boolean closeEnds(byte[] body, int pos) { + int p = pos; + while (p < body.length && (body[p] == ' ' || body[p] == '\t')) { + p++; + } + return p == body.length || crlfAt(body, p); + } + + /// The value of `name` in a urlencoded form, or null. `+` is a space, as the + /// form encoding defines; a malformed escape is kept as written. + static String formValue(String form, String name) { + if (form == null || name == null) { + return null; + } + int pos = 0; + while (pos <= form.length()) { + int end = form.indexOf('&', pos); + if (end < 0) { + end = form.length(); + } + int eq = form.indexOf('=', pos); + if (eq < 0 || eq > end) { + eq = end; + } + if (name.equals(decode(form.substring(pos, eq)))) { + return decode(eq < end ? form.substring(eq + 1, end) : ""); + } + pos = end + 1; + } + return null; + } + + private static String decode(String value) { + if (value.indexOf('%') < 0 && value.indexOf('+') < 0) { + return value; + } + byte[] out = new byte[value.length()]; + int n = 0; + for (int iter = 0 ; iter < value.length() ; iter++) { + char c = value.charAt(iter); + if (c == '+') { + out[n++] = ' '; + } else if (c == '%' && iter + 2 < value.length() + && hex(value.charAt(iter + 1)) >= 0 && hex(value.charAt(iter + 2)) >= 0) { + out[n++] = (byte) (hex(value.charAt(iter + 1)) * 16 + hex(value.charAt(iter + 2))); + iter += 2; + } else if (c < 0x80) { + out[n++] = (byte) c; + } else { + // Already text: re-encode it so the decode below reads it back. A + // surrogate pair is one character and is encoded together; one + // surrogate at a time came out as two replacement characters. + boolean pair = c >= 0xD800 && c <= 0xDBFF && iter + 1 < value.length() + && value.charAt(iter + 1) >= 0xDC00 && value.charAt(iter + 1) <= 0xDFFF; + byte[] utf = utf8(pair ? value.substring(iter, iter + 2) : String.valueOf(c)); + if (pair) { + iter++; + } + if (n + utf.length > out.length) { + byte[] grown = new byte[out.length + utf.length + 16]; + System.arraycopy(out, 0, grown, 0, n); + out = grown; + } + System.arraycopy(utf, 0, out, n, utf.length); + n += utf.length; + } + } + try { + return new String(out, 0, n, "UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString(), err); + } + } + + private static byte[] utf8(String value) { + try { + return value.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString(), err); + } + } + + private static int hex(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + /// What follows the type of a header value such as `form-data; name="x"`. + private static String afterType(String value) { + int semi = value.indexOf(';'); + return semi < 0 ? "" : value.substring(semi + 1); + } + + /// The value of `name` among `; key=value` parameters, a quoted string + /// unquoted. Names compare without case, values as written. + static String parameter(String params, String name) { + int pos = 0; + int length = params.length(); + while (pos < length) { + while (pos < length && (params.charAt(pos) == ' ' || params.charAt(pos) == '\t' + || params.charAt(pos) == ';')) { + pos++; + } + int eq = params.indexOf('=', pos); + if (eq < 0) { + return null; + } + String key = params.substring(pos, eq).trim(); + pos = eq + 1; + String value; + if (pos < length && params.charAt(pos) == '"') { + StringBuilder sb = new StringBuilder(); + pos++; + while (pos < length && params.charAt(pos) != '"') { + char c = params.charAt(pos); + if (c == '\\' && pos + 1 < length) { + pos++; + c = params.charAt(pos); + } + sb.append(c); + pos++; + } + pos++; + value = sb.toString(); + } else { + int end = params.indexOf(';', pos); + if (end < 0) { + end = length; + } + value = params.substring(pos, end).trim(); + pos = end; + } + if (key.equalsIgnoreCase(name)) { + return value; + } + } + return null; + } + + private static boolean crlfAt(byte[] data, int pos) { + return pos + 1 < data.length && data[pos] == '\r' && data[pos + 1] == '\n'; + } + + private static int indexOfCrLf(byte[] data, int from) { + for (int iter = from ; iter + 1 < data.length ; iter++) { + if (data[iter] == '\r' && data[iter + 1] == '\n') { + return iter; + } + } + return -1; + } + + /// Where `needle` first occurs in `data` at or after `from`, or -1, in linear + /// time (Knuth-Morris-Pratt). The boundary is the client's, up to 70 bytes: + /// a naive search restarted the comparison at every matching first byte, so a + /// boundary of dashes over a body of dashes cost hundreds of millions of + /// comparisons per request before it was refused. + private static int indexOf(byte[] data, byte[] needle, int from) { + int[] fallback = new int[needle.length]; + int k = 0; + for (int iter = 1 ; iter < needle.length ; iter++) { + while (k > 0 && needle[iter] != needle[k]) { + k = fallback[k - 1]; + } + if (needle[iter] == needle[k]) { + k++; + } + fallback[iter] = k; + } + k = 0; + for (int iter = Math.max(from, 0) ; iter < data.length ; iter++) { + while (k > 0 && data[iter] != needle[k]) { + k = fallback[k - 1]; + } + if (data[iter] == needle[k]) { + k++; + } + if (k == needle.length) { + return iter - needle.length + 1; + } + } + return -1; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0 ; iter < out.length ; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } + + private static String latin1(byte[] data, int offset, int length) { + char[] out = new char[length]; + for (int iter = 0 ; iter < length ; iter++) { + out[iter] = (char) (data[offset + iter] & 0xff); + } + return new String(out); + } + + private static String asciiLower(String value) { + char[] chars = value.toCharArray(); + for (int iter = 0 ; iter < chars.length ; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } +} diff --git a/vm/backend/src/com/codename1/backend/RequestLog.java b/vm/backend/src/com/codename1/backend/RequestLog.java index c03206a588a..4448dbaf1ee 100644 --- a/vm/backend/src/com/codename1/backend/RequestLog.java +++ b/vm/backend/src/com/codename1/backend/RequestLog.java @@ -32,7 +32,7 @@ /// /// Off unless the development tools turn it on. Off, recording is a static /// read per request and nothing else. -public final class RequestLog { +final class RequestLog { /// Per server, never per process: with two servers in one process, the one /// running the development tools must not show the other's request targets /// and exceptions. Volatile: the development tools switch it on after the @@ -42,9 +42,6 @@ public final class RequestLog { private int next; private int size; - RequestLog() { - } - /// Starts keeping the last `capacity` requests. public synchronized void enable(int capacity) { ring = new Map[capacity < 1 ? 1 : capacity]; diff --git a/vm/backend/src/com/codename1/backend/SessionStore.java b/vm/backend/src/com/codename1/backend/SessionStore.java index c7ec285b193..e867ccc6b32 100644 --- a/vm/backend/src/com/codename1/backend/SessionStore.java +++ b/vm/backend/src/com/codename1/backend/SessionStore.java @@ -29,7 +29,7 @@ /// Two are provided, chosen by `cn1.session.store`: `memory` (the /// default) and `db`, which keeps them in the server's database so any /// instance behind a load balancer can serve any client. Implement this for -/// another -- a cache server -- and pass it to [Sessions#setStore]. +/// another -- a cache server -- and pass it to [Backend.Builder#sessionStore]. public interface SessionStore { /// The session with this id, or null when there is none or it expired. HttpSession load(String id) throws IOException; diff --git a/vm/backend/src/com/codename1/backend/Sessions.java b/vm/backend/src/com/codename1/backend/Sessions.java index 3c1737b9b97..41b5e286492 100644 --- a/vm/backend/src/com/codename1/backend/Sessions.java +++ b/vm/backend/src/com/codename1/backend/Sessions.java @@ -22,6 +22,7 @@ */ package com.codename1.backend; + import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; @@ -46,7 +47,7 @@ /// Nothing here runs for a request that does not ask for its session: the /// cookie is parsed on the first `getSession`, and a request that never /// calls it costs one field check when it ends. -public final class Sessions { +final class Sessions { private String cookieName = "CN1SESSION"; private int timeoutSeconds = 1800; private String sameSite = "Lax"; @@ -54,9 +55,9 @@ public final class Sessions { private SessionStore store = new Memory(); private long lastPurge; private static final long PURGE_INTERVAL = 60000L; - /// Runs the destroy methods of @SessionScope beans; null without an application. - private final Backend.Application application; - /// The @SessionScope beans of every session that has any, by session id. + /// Runs the destroy methods of session-scoped beans; null without an application. + private final com.codename1.impl.backend.BackendApplication application; + /// The session-scoped beans of every session that has any, by session id. /// Kept here rather than trusted to the store: a database store hands back a /// NEW HttpSession on every request, so beans living only on that object /// would be built again per request and never destroyed. @@ -72,7 +73,7 @@ public Sessions() { this(null); } - Sessions(Backend.Application application) { + Sessions(com.codename1.impl.backend.BackendApplication application) { this.application = application; } @@ -89,7 +90,7 @@ public Sessions() { /// /// - `application`: destroys the session-scoped beans, or null public static Sessions configure(Config config, boolean tls, DataSource pool, - Backend.Application application) throws IOException { + com.codename1.impl.backend.BackendApplication application) throws IOException { Sessions out = new Sessions(application); out.cookieName = config.get("cn1.session.cookie", "CN1SESSION"); if (!isToken(out.cookieName)) { diff --git a/vm/backend/src/com/codename1/backend/TaskExecutor.java b/vm/backend/src/com/codename1/backend/TaskExecutor.java index fc5f2d13b2f..d3e200c970d 100644 --- a/vm/backend/src/com/codename1/backend/TaskExecutor.java +++ b/vm/backend/src/com/codename1/backend/TaskExecutor.java @@ -22,14 +22,16 @@ */ package com.codename1.backend; +import com.codename1.impl.backend.AsyncTask; + import java.util.LinkedList; /// A named place background work runs: a pool of platform threads, or virtual /// threads on the server's hosts. /// -/// Configured as `cn1.task.executor..threads` and -/// `cn1.task.executor..kind` (`platform` or `virtual`), -/// and obtained through [Tasks#executor]. The threads of a pool start on the +/// Configured as `cn1.task.executor..threads` and +/// `cn1.task.executor..kind` (`platform` or `virtual`), +/// and listed by [Tasks#executors]. The threads of a pool start on the /// first task, so an executor the configuration names and nothing uses costs a /// map entry. /// diff --git a/vm/backend/src/com/codename1/backend/Tasks.java b/vm/backend/src/com/codename1/backend/Tasks.java index f2c0038e4e4..f4127350a52 100644 --- a/vm/backend/src/com/codename1/backend/Tasks.java +++ b/vm/backend/src/com/codename1/backend/Tasks.java @@ -22,6 +22,8 @@ */ package com.codename1.backend; +import com.codename1.impl.backend.AsyncTask; + import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -56,14 +58,14 @@ /// virtual thread, and every other virtual thread on that host with it. See /// `com.codename1.backend.annotations.ThreadKind`. public final class Tasks { - public static final int AUTO = 0; - public static final int VIRTUAL = 1; - public static final int PLATFORM = 2; + static final int AUTO = com.codename1.impl.backend.BackendAccess.AUTO; + static final int VIRTUAL = com.codename1.impl.backend.BackendAccess.VIRTUAL; + static final int PLATFORM = com.codename1.impl.backend.BackendAccess.PLATFORM; /// The executor `@Async` methods use when they name none. - public static final String DEFAULT = "default"; + static final String DEFAULT = com.codename1.impl.backend.BackendAccess.DEFAULT_EXECUTOR; /// The executor scheduled jobs use when they name none. - public static final String SCHEDULING = "scheduling"; + static final String SCHEDULING = com.codename1.impl.backend.BackendAccess.SCHEDULING_EXECUTOR; /// The registry of the server the calling thread works for, when it carries one. private static final ThreadLocal CURRENT = new ThreadLocal(); @@ -168,9 +170,9 @@ private static Registry current() { /// #### Parameters /// /// - `kind`: @param kind [#AUTO], [#VIRTUAL] or [#PLATFORM]: what the - /// code asked for, which `cn1.task.executor..kind` + /// code asked for, which `cn1.task.executor..kind` /// overrides - public static TaskExecutor executor(String name, int kind) { + static TaskExecutor executor(String name, int kind) { return executor(current(), name, kind); } @@ -311,7 +313,7 @@ public static List executors() { /// turned metrics off, whose queues would then appear in another server's /// telemetry, the one built-in metric counting work from a server that opted /// out. - public static List executorsOf(java.util.Collection servers) { + static List executorsOf(java.util.Collection servers) { List registries; synchronized (Tasks.class) { registries = new ArrayList(LIVE); @@ -330,7 +332,7 @@ public static List executorsOf(java.util.Collection servers) { /// Stops the executors of the calling thread's server, waiting up to /// `waitMillis` in total for what is running. - public static void shutdown(long waitMillis) { + static void shutdown(long waitMillis) { shutdown(current(), waitMillis); } diff --git a/vm/backend/src/com/codename1/backend/Tracing.java b/vm/backend/src/com/codename1/backend/Tracing.java index dcf7500a292..2342b4c8409 100644 --- a/vm/backend/src/com/codename1/backend/Tracing.java +++ b/vm/backend/src/com/codename1/backend/Tracing.java @@ -452,7 +452,7 @@ public static boolean isSuppressed() { public static void route(String template) { // The request histogram's label, when metrics are on: one static read // when they are not. - com.codename1.backend.metrics.Metrics.route(template); + com.codename1.impl.backend.MetricsAccess.get().route(template); // Every generated router calls this on every matched request, traced or // not; with no tracer that is this one read and nothing else. if (tracer == null && currentOrNull() == null) { diff --git a/vm/backend/src/com/codename1/backend/Transactions.java b/vm/backend/src/com/codename1/backend/Transactions.java index 7fdfd9316c4..a7c1ddfcd5c 100644 --- a/vm/backend/src/com/codename1/backend/Transactions.java +++ b/vm/backend/src/com/codename1/backend/Transactions.java @@ -61,13 +61,13 @@ /// throws [TransactionException.UnexpectedRollback] rather than reporting /// a commit that did not happen. public final class Transactions { - public static final int REQUIRED = 0; - public static final int SUPPORTS = 1; - public static final int MANDATORY = 2; - public static final int REQUIRES_NEW = 3; - public static final int NOT_SUPPORTED = 4; - public static final int NEVER = 5; - public static final int NESTED = 6; + static final int REQUIRED = com.codename1.impl.backend.BackendAccess.REQUIRED; + static final int SUPPORTS = com.codename1.impl.backend.BackendAccess.SUPPORTS; + static final int MANDATORY = com.codename1.impl.backend.BackendAccess.MANDATORY; + static final int REQUIRES_NEW = com.codename1.impl.backend.BackendAccess.REQUIRES_NEW; + static final int NOT_SUPPORTED = com.codename1.impl.backend.BackendAccess.NOT_SUPPORTED; + static final int NEVER = com.codename1.impl.backend.BackendAccess.NEVER; + static final int NESTED = com.codename1.impl.backend.BackendAccess.NESTED; /// The physical transaction the thread is in, or null. private static final ThreadLocal CURRENT = new ThreadLocal(); @@ -120,7 +120,7 @@ static final class Physical { /// What one call to [#begin] did, which the matching [#commit] or /// [#afterThrow] undoes. Opaque to the woven code, which only passes it /// back. - public static final class Transaction { + static final class Transaction { final int kind; final Physical physical; /// The transaction to restore when this one ends: REQUIRES_NEW, NOT_SUPPORTED. @@ -141,7 +141,7 @@ public static final class Transaction { } /// Whether this call began the physical transaction it runs in. - public boolean isNewTransaction() { + boolean isNewTransaction() { return kind == KIND_NEW; } } @@ -200,7 +200,7 @@ public static void setRollbackOnly() { /// - `readOnly`: the annotation's readOnly /// /// - `timeoutSeconds`: the annotation's timeout, zero or less for none - public static Transaction begin(int propagation, boolean readOnly, int timeoutSeconds) { + static Transaction begin(int propagation, boolean readOnly, int timeoutSeconds) { used = true; Transaction tx = open(propagation, readOnly, timeoutSeconds); if (tx.physical != null && (tx.kind == KIND_JOINED || tx.kind == KIND_SAVEPOINT)) { @@ -286,7 +286,7 @@ private static Transaction nested(Physical current) { /// Ends a call that returned normally. Commits when the call began the /// transaction, and does nothing more than restore state when it joined one. - public static void commit(Transaction tx) { + static void commit(Transaction tx) { if (tx == null || tx.completed) { return; } @@ -335,7 +335,7 @@ public static void commit(Transaction tx) { /// A rollback that fails is reported on standard error and its connection /// closed, so the pool cannot hand out a session that is still inside a /// transaction. - public static void afterThrow(Transaction tx, boolean rollback) { + static void afterThrow(Transaction tx, boolean rollback) { if (tx == null || tx.completed) { return; } @@ -513,7 +513,7 @@ private static void flushSession(Physical p) throws IOException { /// The transaction's connection for `pool`, borrowing it and sending /// BEGIN the first time; null when the thread is not in a transaction or its /// transaction is on another database. Called by [DataSource#borrow]. - public static Database joined(DataSource pool) throws IOException { + static Database joined(DataSource pool) throws IOException { if (!used) { return null; } @@ -577,7 +577,7 @@ private static Database materialize(Physical p, DataSource pool) throws IOExcept } /// Whether `db` is the connection of the calling thread's transaction on `pool`. - public static boolean isJoined(DataSource pool, Database db) { + static boolean isJoined(DataSource pool, Database db) { if (!used) { return false; } @@ -586,7 +586,7 @@ public static boolean isJoined(DataSource pool, Database db) { } /// Whether the calling thread's transaction is on `pool`, or could be. - public static boolean isActiveOn(DataSource pool) { + static boolean isActiveOn(DataSource pool) { if (!used) { return false; } @@ -601,7 +601,7 @@ public static boolean isActiveOn(DataSource pool) { /// This is what an injected `Session` delegates to. Outside a /// transaction there is no session to give, and the refusal says where one /// comes from. - public static com.codename1.orm.session.Session session(EntityManager entities) { + static com.codename1.orm.session.Session session(EntityManager entities) { Physical p = used ? (Physical) CURRENT.get() : null; if (p == null) { throw new TransactionException.IllegalState("The injected Session belongs to a " @@ -639,14 +639,14 @@ public static com.codename1.orm.session.Session session(EntityManager entities) /// transaction they are in cannot commit either. /// Marks the calling thread's transaction, whatever pool it is on, as /// unable to commit; nothing when there is none. - public static void markRollbackOnly() { + static void markRollbackOnly() { Physical p = used ? (Physical) CURRENT.get() : null; if (p != null) { p.rollbackOnly = true; } } - public static void markRollbackOnly(DataSource pool) { + static void markRollbackOnly(DataSource pool) { Physical p = used ? (Physical) CURRENT.get() : null; if (p != null && (p.pool == null || p.pool == pool)) { //NOPMD CompareObjectsWithEquals - pools and connections are compared by identity p.rollbackOnly = true; diff --git a/vm/backend/src/com/codename1/backend/annotations/Component.java b/vm/backend/src/com/codename1/backend/annotations/Component.java index dbb4f4c04c0..6fa8dc6f796 100644 --- a/vm/backend/src/com/codename1/backend/annotations/Component.java +++ b/vm/backend/src/com/codename1/backend/annotations/Component.java @@ -41,9 +41,6 @@ /// public long now() { return System.currentTimeMillis(); } /// } /// ``` -/// -/// [Service] and [Repository] mean the same thing and exist so a class can say -/// which layer it belongs to, as they do in Spring. @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) public @interface Component { diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableManagement.java b/vm/backend/src/com/codename1/backend/annotations/EnableManagement.java deleted file mode 100644 index f8417d19efe..00000000000 --- a/vm/backend/src/com/codename1/backend/annotations/EnableManagement.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2012, 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. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.backend.annotations; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/// Builds the management endpoints into the server: health, metrics, the -/// Prometheus view, scheduled jobs and managed beans, under `/manage`. -/// -/// ```java -/// @EnableManagement -/// @Configuration -/// public class Settings { } -/// ``` -/// -/// THIS IS A BUILD-TIME SWITCH. Without it -- or `cn1.management.enabled=true` -/// in `application.properties` or a profile's file -- the entry point never names -/// the endpoints and the translator leaves their code out of the binary, so a -/// packaged server has no `/manage` to find, whatever its profile. The development -/// run always has them. -/// -/// With it, the endpoints are on unless `cn1.management.enabled=false` turns them -/// off at start-up, and outside a development profile they refuse to start -/// without `cn1.management.token`, which belongs in the environment rather than -/// in source. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface EnableManagement { - /// Where the endpoints are served; `cn1.management.path`. Empty means - /// `/manage`. - String path() default ""; -} diff --git a/vm/backend/src/com/codename1/backend/annotations/RequestParam.java b/vm/backend/src/com/codename1/backend/annotations/RequestParam.java index 62b142425c1..940513bffac 100644 --- a/vm/backend/src/com/codename1/backend/annotations/RequestParam.java +++ b/vm/backend/src/com/codename1/backend/annotations/RequestParam.java @@ -27,7 +27,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Binds a query parameter to this parameter. +/// Binds a request parameter to this parameter: the query string's value, or, +/// when the query has none, a field of the form the body carries -- +/// `application/x-www-form-urlencoded`, or a `multipart/form-data` part that is +/// not a file. That is what a servlet container and Spring read, and what an HTML +/// form posts. A file part is bound with [RequestPart]. /// /// A missing value binds to the default below, or to null when the parameter is /// not required. It is never a server error unless `required` says so. diff --git a/vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java b/vm/backend/src/com/codename1/backend/annotations/RequestPart.java similarity index 60% rename from vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java rename to vm/backend/src/com/codename1/backend/annotations/RequestPart.java index b98c3b1551f..4214556759b 100644 --- a/vm/backend/src/com/codename1/backend/annotations/DataSourceConfig.java +++ b/vm/backend/src/com/codename1/backend/annotations/RequestPart.java @@ -27,25 +27,26 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Connection pool settings, compiled in. +/// Binds one part of a `multipart/form-data` request body to this parameter. /// -/// Each attribute is the `cn1.datasource.*` key named beside it, and sets that -/// key's value at the bottom of the configuration, so `DATABASE_URL`, a -/// properties file or the environment still override it. An attribute left at its -/// default sets nothing. Keep credentials out of source: a URL may name an -/// environment variable as `${NAME}`, resolved when the server starts. +/// ```java +/// @PostMapping("/avatar") +/// public String upload(@RequestPart("file") HttpServer.Part file) { +/// store(file.getFilename(), file.getBytes()); +/// return "stored " + file.getSize() + " bytes"; +/// } +/// ``` +/// +/// The parameter is a `com.codename1.backend.HttpServer.Part` -- name, filename, +/// content type and bytes -- or `byte[]` for the content alone, or `String` for +/// a text field. A body that is not well-formed multipart is answered 400 before +/// the method runs, as is a missing required part. @Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface DataSourceConfig { - /// `cn1.datasource.url`: a SQLite path, or a `postgres://` or `mysql://` URL. - String url() default ""; - - /// `cn1.datasource.pool.size`. - int poolSize() default -1; - - /// `cn1.datasource.pool.borrowTimeoutMillis`. - int borrowTimeoutMillis() default -1; - - /// `cn1.datasource.busyTimeoutMillis`: how long SQLite waits on a locked file. - int busyTimeoutMillis() default -1; +@Target(ElementType.PARAMETER) +public @interface RequestPart { + /// The part's name, from its Content-Disposition. Required for the same reason + /// [RequestParam]'s is: a parameter's own name does not survive compilation. + String value(); + /// Whether a request without the part is rejected. + boolean required() default true; } diff --git a/vm/backend/src/com/codename1/backend/annotations/SessionConfig.java b/vm/backend/src/com/codename1/backend/annotations/SessionConfig.java deleted file mode 100644 index 8d6719e9978..00000000000 --- a/vm/backend/src/com/codename1/backend/annotations/SessionConfig.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2012, 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. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.backend.annotations; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/// Session settings, compiled in. -/// -/// Each attribute is the `cn1.session.*` key named beside it, and sets that key's -/// value at the bottom of the configuration: `application.properties`, a -/// profile's file or the environment still change it without a rebuild. An -/// attribute left at its default sets nothing. -/// -/// ```java -/// @SessionConfig(store = "db", timeoutSeconds = 3600) -/// public class Settings { } -/// ``` -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface SessionConfig { - /// `cn1.session.store`: `memory` or `db`. - String store() default ""; - - /// `cn1.session.timeout`: seconds of inactivity before a session ends, 0 for - /// never. - int timeoutSeconds() default -1; - - /// `cn1.session.cookie`: the cookie's name. - String cookie() default ""; - - /// `cn1.session.same-site`: `Lax`, `Strict` or `None`. - String sameSite() default ""; - - /// `cn1.session.secure`: `auto`, `true` or `false`. - String secure() default ""; - - /// `cn1.session.namespace`: the prefix that separates this server's sessions - /// in a shared store. - String namespace() default ""; -} diff --git a/vm/backend/src/com/codename1/backend/metrics/Metrics.java b/vm/backend/src/com/codename1/backend/metrics/Metrics.java index b422df4c671..815c3a4f015 100644 --- a/vm/backend/src/com/codename1/backend/metrics/Metrics.java +++ b/vm/backend/src/com/codename1/backend/metrics/Metrics.java @@ -57,6 +57,53 @@ /// and status), and the server's counters, the database pool, the task executors /// and the process's memory are published as gauges. public final class Metrics { + static { + com.codename1.impl.backend.MetricsAccess.install(new com.codename1.impl.backend.MetricsAccess() { + @Override + public void enableServer(com.codename1.backend.HttpServer server, + com.codename1.backend.DataSource pool) { + Metrics.enableServer(server, pool); + } + + @Override + public void disableServer(com.codename1.backend.HttpServer server, + com.codename1.backend.DataSource pool) { + Metrics.disableServer(server, pool); + } + + @Override + public void route(String template) { + Metrics.route(template); + } + + @Override + public long requestStarted() { + return Metrics.requestStarted(); + } + + @Override + public void requestEnded(long started, String method, int status) { + Metrics.requestEnded(started, method, status); + } + + @Override + public void jobRan(String job, long millis, boolean failed) { + Metrics.jobRan(job, millis, failed); + } + + @Override + public void addSource(String name, String description, String unit, + Gauge.Source source) { + Metrics.addSource(name, description, unit, source); + } + + @Override + public void removeSource(String name, Gauge.Source source) { + Metrics.removeSource(name, source); + } + }); + } + private static final Map INSTRUMENTS = new LinkedHashMap(); private static final long STARTED = System.currentTimeMillis(); @@ -266,7 +313,7 @@ public static long startTimeMillis() { /// gauges go through here and are removed when it stops, so a stopped /// server's bean is never read again and a second live server adds to the /// gauge instead of silently replacing the first one's. - public static synchronized void addSource(String name, String description, String unit, + static synchronized void addSource(String name, String description, String unit, Gauge.Source source) { List sources = (List) SHARED.get(name); if (sources != null) { @@ -316,7 +363,7 @@ public double read() { } /// Removes a source [#addSource] added; the gauge goes with its last one. - public static synchronized void removeSource(String name, Gauge.Source source) { + static synchronized void removeSource(String name, Gauge.Source source) { List sources = (List) SHARED.get(name); if (sources == null) { return; @@ -385,7 +432,7 @@ private static synchronized void endSourceRead(Object source) { /// the servers still running, and the request histogram counts both. Each /// server used to register its own source under the same name, so the last /// one started silently replaced the others' -- and a stopped server's stayed. - public static void enableServer(final com.codename1.backend.HttpServer server, + static void enableServer(final com.codename1.backend.HttpServer server, final com.codename1.backend.DataSource pool) { synchronized (Metrics.class) { if (!LIVE_SERVERS.contains(server)) { @@ -436,7 +483,7 @@ public List read() { // Only the servers that measure: one with metrics off keeps // its queues out of the others' telemetry, as its // requests and jobs are. - List all = com.codename1.backend.Tasks.executorsOf(liveServers()); + List all = com.codename1.impl.backend.BackendAccess.get().executorsOf(liveServers()); for (Object element : all) { com.codename1.backend.TaskExecutor e = (com.codename1.backend.TaskExecutor) element; @@ -474,7 +521,7 @@ public double read() { /// A server has stopped: its gauges stop counting it, and once none is left /// the server instruments stop recording. - public static void disableServer(com.codename1.backend.HttpServer server, + static void disableServer(com.codename1.backend.HttpServer server, com.codename1.backend.DataSource pool) { synchronized (Metrics.class) { LIVE_SERVERS.remove(server); @@ -524,19 +571,19 @@ public double read() { } /// Records which route template matched, for the request histogram. - public static void route(String template) { + static void route(String template) { if (serverEnabled) { ROUTE.set(template); } } /// A request is starting; answers the time to hand to [#requestEnded]. - public static long requestStarted() { + static long requestStarted() { return serverEnabled ? System.nanoTime() : 0L; } /// A request has been answered. - public static void requestEnded(long started, String method, int status) { + static void requestEnded(long started, String method, int status) { // The route is cleared on every path: a server that does not measure // still has its routers call route(), and a value left behind would be // recorded under the next request this thread serves. @@ -552,7 +599,7 @@ public static void requestEnded(long started, String method, int status) { } /// A scheduled job has run. - public static void jobRan(String job, long millis, boolean failed) { + static void jobRan(String job, long millis, boolean failed) { if (serverEnabled) { jobDuration.record(millis, job, failed ? "failure" : "success", null); } diff --git a/vm/backend/src/com/codename1/backend/orm/EntityManager.java b/vm/backend/src/com/codename1/backend/orm/EntityManager.java index 7ddf928b692..a4221818275 100644 --- a/vm/backend/src/com/codename1/backend/orm/EntityManager.java +++ b/vm/backend/src/com/codename1/backend/orm/EntityManager.java @@ -266,7 +266,7 @@ public Object transaction(final Work body) throws Exception { // BEGIN left the writes before a failure committed. return pinned.transaction(new ScopedWork(body, dialect, tables)); } - Database joined = com.codename1.backend.Transactions.joined(pool); + Database joined = com.codename1.impl.backend.BackendAccess.get().joined(pool); if (joined != null) { // Inside a @Transactional method: join its transaction, as a manager // already inside one does above. @@ -282,10 +282,10 @@ private static Object joinedRun(Work body, EntityManager manager) throws Excepti try { return body.run(manager); } catch (Exception err) { - com.codename1.backend.Transactions.markRollbackOnly(); + com.codename1.impl.backend.BackendAccess.get().markRollbackOnly(); throw err; } catch (Error err) { - com.codename1.backend.Transactions.markRollbackOnly(); + com.codename1.impl.backend.BackendAccess.get().markRollbackOnly(); throw err; } } diff --git a/vm/backend/src/com/codename1/backend/AsyncTask.java b/vm/backend/src/com/codename1/impl/backend/AsyncTask.java similarity index 95% rename from vm/backend/src/com/codename1/backend/AsyncTask.java rename to vm/backend/src/com/codename1/impl/backend/AsyncTask.java index 2868a0e49d6..c88346e5e8d 100644 --- a/vm/backend/src/com/codename1/backend/AsyncTask.java +++ b/vm/backend/src/com/codename1/impl/backend/AsyncTask.java @@ -20,7 +20,13 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; + +import com.codename1.backend.Tracing; +import com.codename1.backend.VirtualThread; + +import com.codename1.backend.Span; +import com.codename1.backend.Tracer; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -63,8 +69,8 @@ public abstract class AsyncTask implements Runnable, Future { protected AsyncTask(String name, boolean returnsVoid) { this.name = name; this.returnsVoid = returnsVoid; - this.parent = Tracing.captureParent(); - this.owner = Tracing.captureOwner(); + this.parent = BackendAccess.get().captureParent(); + this.owner = BackendAccess.get().captureOwner(); } /// Runs the method's body. Generated. @@ -82,7 +88,7 @@ public final void run() { Throwable error = null; AsyncTask chained = null; try { - result = Tracing.inBackground(name, parent, owner, new Tracing.Work() { + result = BackendAccess.get().inBackground(name, parent, owner, new Tracing.Work() { @Override public Object run(Span span) throws Exception { return call(); @@ -140,7 +146,7 @@ public void run() { /// Ends a call whose virtual thread the server freed at shutdown before it /// finished: nothing will ever complete it otherwise, and a caller blocked /// in get() would wait for ever. - void abandon(String reason) { + public void abandon(String reason) { java.util.List fire; synchronized (this) { if (done) { @@ -278,7 +284,7 @@ public Object get(long timeout, TimeUnit unit) /// saturates a huge timeout -- get(Long.MAX_VALUE, DAYS) -- to Long.MAX_VALUE, /// and adding the clock to that wrapped to a deadline in the past, so the /// longest possible wait timed out at once. - static long deadline(long now, long millis) { + public static long deadline(long now, long millis) { if (millis > 0 && now > Long.MAX_VALUE - millis) { return Long.MAX_VALUE; } @@ -311,7 +317,7 @@ private boolean awaitCooperatively(long deadline) { if (now >= deadline) { return false; } - HttpServer.napUntil(Math.min(deadline, now + nap)); + BackendAccess.get().napUntil(Math.min(deadline, now + nap)); nap = Math.min(20, nap * 2); } return true; diff --git a/vm/backend/src/com/codename1/impl/backend/BackendAccess.java b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java new file mode 100644 index 00000000000..3a08c350ed0 --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.Database; +import com.codename1.backend.HttpServer; +import com.codename1.backend.HttpSession; +import com.codename1.backend.Span; +import com.codename1.backend.TaskExecutor; +import com.codename1.backend.Tracer; +import com.codename1.backend.Tracing; +import com.codename1.backend.orm.EntityManager; +import com.codename1.impl.backend.mcp.McpServer; +import com.codename1.impl.backend.mcp.McpTool; +import java.io.IOException; +import java.util.Collection; +import java.util.List; + +/// The runtime's internals, for the code the build generates and for the runtime's +/// own classes in other packages. +/// +/// `com.codename1.backend` is what an application is written against, and its +/// javadoc is the backend's public API. The generated wiring, the woven aspects and +/// the runtime's MCP, management and scheduling classes need more than that -- to +/// begin a transaction around a method, to hand a request its scoped beans, to +/// install an application into the builder -- and making those members public +/// would publish them as API. They are package-private instead, and this is the +/// one door to them: an abstract class whose only implementation lives in +/// `com.codename1.backend` and installs itself when [Backend] is initialized. +/// +/// Nothing here is a contract with applications. It changes whenever the build's +/// output changes, which is why it lives under `com.codename1.impl`. +public abstract class BackendAccess { + /// [#executor]'s thread kind: whatever the platform runs best. + public static final int AUTO = 0; + /// [#executor]'s thread kind: virtual threads. + public static final int VIRTUAL = 1; + /// [#executor]'s thread kind: platform threads. + public static final int PLATFORM = 2; + /// The executor `@Async` work goes to when it names none. + public static final String DEFAULT_EXECUTOR = "default"; + /// The executor scheduled jobs go to when they name none. + public static final String SCHEDULING_EXECUTOR = "scheduling"; + + /// `@Transactional`'s propagation values, in the order of the annotation's enum. + public static final int REQUIRED = 0; + /// See [#REQUIRED]. + public static final int SUPPORTS = 1; + /// See [#REQUIRED]. + public static final int MANDATORY = 2; + /// See [#REQUIRED]. + public static final int REQUIRES_NEW = 3; + /// See [#REQUIRED]. + public static final int NOT_SUPPORTED = 4; + /// See [#REQUIRED]. + public static final int NEVER = 5; + /// See [#REQUIRED]. + public static final int NESTED = 6; + + private static BackendAccess instance; + + /// Called once, by the implementation, from [Backend]'s static initializer. + public static synchronized void install(BackendAccess access) { + if (instance != null) { + throw new IllegalStateException("BackendAccess is already installed"); + } + instance = access; + } + + /// The implementation, initializing [Backend] first if nothing has yet. + public static BackendAccess get() { + BackendAccess a = instance; + if (a == null) { + // A static call is what makes a class initialize, on the JVM and under + // ParparVM alike; a class literal does not. currentRequest() has no + // other effect. + Backend.currentRequest(); + a = instance; + if (a == null) { + throw new IllegalStateException("The backend runtime did not install its access"); + } + } + return a; + } + + // ---------------------------------------------------------- the builder + + /// Installs the generated wiring of an application. + public abstract void application(Backend.Builder builder, BackendApplication application); + + /// Serves the MCP endpoint, with `devTools` on a development profile when given. + public abstract void mcp(Backend.Builder builder, McpServer.Extension devTools); + + /// Serves the management endpoints when the configuration turns them on. + public abstract void management(Backend.Builder builder); + + /// The module's compiled-in settings: the bottom layer of the configuration. + public abstract void compiledSettings(Backend.Builder builder, String[] keysAndValues); + + /// A configuration whose `values` win over every layer, the environment + /// included: the settings a test names for itself. + public abstract Config testConfig(java.util.Properties values, String profile); + + /// Adds a tool to the MCP endpoint. + public abstract void mcpTool(Backend.Builder builder, McpTool tool); + + /// The name the server reports to MCP clients. + public abstract void serviceName(Backend.Builder builder, String name); + + // ------------------------------------------------------------ a server + + /// The server's generated wiring, or null. + public abstract BackendApplication applicationOf(Backend backend); + + /// A copy of the server's managed beans. + public abstract List managedBeans(Backend backend); + + /// Whether the server records request and job metrics. + public abstract boolean isMeasured(Backend backend); + + /// Turns on the server's recent-request log, keeping `capacity` entries. + public abstract void enableRequestLog(Backend backend, int capacity); + + /// The server's recent requests, newest first; see [#enableRequestLog]. + public abstract List recentRequests(Backend backend, int limit, boolean failuresOnly); + + /// Answers a request the way the listener would -- the body decoded and + /// refused by the same rules, then sessions, scoped beans, tracing and metrics + /// -- on the calling thread, without a socket. `headers` maps a name to its + /// value, in any case; `body` may be null. + public abstract HttpServer.Response dispatch(Backend backend, String method, String target, + java.util.Map headers, byte[] body) + throws Exception; + + // ------------------------------------------------- requests and sessions + + /// The response's status, headers and body, with deferred JSON serialized. + public abstract int status(HttpServer.Response response); + + /// The response's headers, as name and value pairs in the order they were set. + public abstract List headers(HttpServer.Response response); + + /// The response's body, serialized; never null. + public abstract byte[] body(HttpServer.Response response) throws IOException; + + /// Releases what `response` holds without reading it -- the file a static file + /// response keeps open -- for a caller that sends no body, as a HEAD answer. + public abstract void discard(HttpServer.Response response); + + /// The response's content type, or null. + public abstract String contentType(HttpServer.Response response); + + /// The request's request-scoped beans, grown to `count` slots. + public abstract Object[] requestBeans(HttpServer.Request request, int count); + + /// The lock guarding a session's session-scoped beans. + public abstract Object sessionBeanLock(HttpSession session); + + /// The session's session-scoped beans, grown to `count` slots. + public abstract Object[] sessionBeans(HttpSession session, int count); + + /// Sleeps until `deadlineMillis`, yielding a virtual thread rather than its host. + public abstract void napUntil(long deadlineMillis); + + // --------------------------------------------------------------- tracing + + /// The span the calling thread is in, for work it hands to another thread. + public abstract Span captureParent(); + + /// The tracer the calling thread's server owns. + public abstract Tracer captureOwner(); + + /// The tracer that records nothing. + public abstract Tracer untraced(); + + /// Runs `work` in a span named `name`, under `parent` and recorded by `own`. + public abstract Object inBackground(String name, Span parent, Tracer own, Tracing.Work work) + throws Exception; + + // ---------------------------------------------------------- transactions + + /// Begins a woven `@Transactional` method; the result is handed back to + /// [#commit] or [#afterThrow]. + public abstract Object begin(int propagation, boolean readOnly, int timeoutSeconds); + + /// Ends a transaction [#begin] returned, normally. + public abstract void commit(Object transaction); + + /// Ends a transaction [#begin] returned, after its method threw. + public abstract void afterThrow(Object transaction, boolean rollback); + + /// The connection the current transaction holds on `pool`, borrowing one if + /// the transaction has not touched it yet; null outside a transaction. + public abstract Database joined(DataSource pool) throws IOException; + + /// Whether `db` is the connection a transaction holds on `pool`. + public abstract boolean isJoined(DataSource pool, Database db); + + /// Whether a transaction is active on `pool`. + public abstract boolean isActiveOn(DataSource pool); + + /// The ORM session bound to the current transaction. + public abstract com.codename1.orm.session.Session session(EntityManager entities); + + /// Marks the current transaction rollback-only after a failure. + public abstract void markRollbackOnly(); + + /// Marks the transaction on `pool` rollback-only after a failure. + public abstract void markRollbackOnly(DataSource pool); + + // -------------------------------------------------------------- executors + + /// The named executor of the calling thread's server, created on first use. + public abstract TaskExecutor executor(String name, int kind); + + /// Every executor of the given servers. + public abstract List executorsOf(Collection servers); +} diff --git a/vm/backend/src/com/codename1/impl/backend/BackendApplication.java b/vm/backend/src/com/codename1/impl/backend/BackendApplication.java new file mode 100644 index 00000000000..e8fc5464a4e --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/BackendApplication.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.Backend; +import com.codename1.backend.HttpServer; +import java.util.List; + +/// The build-generated wiring of an application: every bean, constructed and +/// injected by straight-line code the build wrote, and the lifecycle calls +/// around them. +/// +/// Nothing here is looked up or reflected. The build resolves which +/// constructor each bean gets, which bean each injection point receives and +/// in what order they are built, and writes that down as `new` and +/// setter calls; this interface is only where the server calls into it. +public interface BackendApplication { + /// Constructs the beans and returns the routers, once the database, if + /// any, is open. + HttpServer.Handler[] create(WiringEnvironment environment) throws Exception; + + /// Registers the websocket endpoints, which are beans too. + void registerWebSockets(HttpServer.WebSocketRegistry registry) throws Exception; + + /// The server is accepting: scheduled jobs and exporters start here. + void started(Backend backend) throws Exception; + + /// The server is about to drain: no new scheduled run starts after this. + void stopping(); + + /// The server has drained: the beans' destroy methods run here. + void stopped(); + + /// Whether a generated class needs `Backend.currentRequest()`: a + /// request- or session-scoped bean reached from a singleton. False keeps + /// the per-request thread-local write out of servers that have none. + boolean tracksCurrentRequest(); + + /// A request has been answered; `beans` are its + /// request-scoped beans, whose destroy methods run here. + void requestEnded(Object[] beans); + + /// A session has ended -- invalidated, expired, or the server stopped; + /// `beans` are its session-scoped beans, whose destroy + /// methods run here. + void sessionEnded(Object[] beans); + + /// The scheduler running this application's `@Scheduled` jobs, or null. + Scheduler getScheduler(); + + /// Every bean the build wired: name, type, scope and what it was given. + /// For the management endpoint and the development MCP server. + List describeBeans(); + + /// Every route the build generated: method, path and handler. + List describeRoutes(); +} diff --git a/vm/backend/src/com/codename1/backend/CronSchedule.java b/vm/backend/src/com/codename1/impl/backend/CronSchedule.java similarity index 99% rename from vm/backend/src/com/codename1/backend/CronSchedule.java rename to vm/backend/src/com/codename1/impl/backend/CronSchedule.java index f68bbed82ab..8029ed634d3 100644 --- a/vm/backend/src/com/codename1/backend/CronSchedule.java +++ b/vm/backend/src/com/codename1/impl/backend/CronSchedule.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; import java.util.TimeZone; diff --git a/vm/backend/src/com/codename1/backend/JsonCodec.java b/vm/backend/src/com/codename1/impl/backend/JsonCodec.java similarity index 99% rename from vm/backend/src/com/codename1/backend/JsonCodec.java rename to vm/backend/src/com/codename1/impl/backend/JsonCodec.java index b657441e1fc..d494dd2086c 100644 --- a/vm/backend/src/com/codename1/backend/JsonCodec.java +++ b/vm/backend/src/com/codename1/impl/backend/JsonCodec.java @@ -20,7 +20,11 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; + +import com.codename1.backend.Base64; + +import com.codename1.backend.ByteSink; import java.util.Date; diff --git a/vm/backend/src/com/codename1/backend/ManagedBean.java b/vm/backend/src/com/codename1/impl/backend/ManagedBean.java similarity index 98% rename from vm/backend/src/com/codename1/backend/ManagedBean.java rename to vm/backend/src/com/codename1/impl/backend/ManagedBean.java index f3792e65450..425c11919c9 100644 --- a/vm/backend/src/com/codename1/backend/ManagedBean.java +++ b/vm/backend/src/com/codename1/impl/backend/ManagedBean.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; import java.util.Map; diff --git a/vm/backend/src/com/codename1/backend/Management.java b/vm/backend/src/com/codename1/impl/backend/Management.java similarity index 96% rename from vm/backend/src/com/codename1/backend/Management.java rename to vm/backend/src/com/codename1/impl/backend/Management.java index c53ec7b807c..323d179ca10 100644 --- a/vm/backend/src/com/codename1/backend/Management.java +++ b/vm/backend/src/com/codename1/impl/backend/Management.java @@ -20,7 +20,14 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Json; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.backend.HttpServer; import java.io.IOException; import java.util.ArrayList; @@ -98,13 +105,13 @@ public static Management fromConfig(Config config) throws IOException { } /// Set once the server is running. - void attach(Backend running) { + public void attach(Backend running) { this.backend = running; } /// The managed beans of the server this endpoint belongs to. private List beans() { - return backend == null ? new ArrayList() : backend.getManagedBeans(); + return backend == null ? new ArrayList() : BackendAccess.get().managedBeans(backend); } /// Each of `all` -- a server's managed beans, from @@ -230,8 +237,8 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { utf8(Metrics.prometheus())); } if ("/jobs".equals(rest)) { - Scheduler scheduler = backend == null || backend.getApplication() == null - ? null : backend.getApplication().getScheduler(); + Scheduler scheduler = backend == null || BackendAccess.get().applicationOf(backend) == null + ? null : BackendAccess.get().applicationOf(backend).getScheduler(); return request.respondJson(200, scheduler == null ? new ArrayList() : scheduler.describe()); } diff --git a/vm/backend/src/com/codename1/impl/backend/MetricsAccess.java b/vm/backend/src/com/codename1/impl/backend/MetricsAccess.java new file mode 100644 index 00000000000..a8fda530a73 --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/MetricsAccess.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.metrics.Gauge; +import com.codename1.backend.metrics.Metrics; + +/// The server's own hooks into `com.codename1.backend.metrics`: counting a +/// request, a job, the gauges of a running server. An application records with +/// [Metrics] and reads with its accessors; these are the calls only the runtime +/// makes, which is why they are package-private there and reached through here. +/// See [BackendAccess] for the pattern. +public abstract class MetricsAccess { + private static MetricsAccess instance; + + /// Called once, by the implementation, from [Metrics]'s static initializer. + public static synchronized void install(MetricsAccess access) { + if (instance != null) { + throw new IllegalStateException("MetricsAccess is already installed"); + } + instance = access; + } + + /// The implementation, initializing [Metrics] first if nothing has yet. + public static MetricsAccess get() { + MetricsAccess a = instance; + if (a == null) { + // A static call initializes the class; startTimeMillis() has no other effect. + Metrics.startTimeMillis(); + a = instance; + if (a == null) { + throw new IllegalStateException("The metrics runtime did not install its access"); + } + } + return a; + } + + /// Starts reporting a server's own instruments: requests, connections, pool. + public abstract void enableServer(HttpServer server, DataSource pool); + + /// Stops reporting a server's own instruments. + public abstract void disableServer(HttpServer server, DataSource pool); + + /// Names the route template the current request matched, for its labels. + public abstract void route(String template); + + /// A request began; the result is handed back to [#requestEnded]. + public abstract long requestStarted(); + + /// A request [#requestStarted] counted has been answered. + public abstract void requestEnded(long started, String method, int status); + + /// A scheduled job ran. + public abstract void jobRan(String job, long millis, boolean failed); + + /// Adds one server's contribution to a gauge several servers may share. + public abstract void addSource(String name, String description, String unit, + Gauge.Source source); + + /// Removes a contribution [#addSource] made. + public abstract void removeSource(String name, Gauge.Source source); +} diff --git a/vm/backend/src/com/codename1/backend/Scheduler.java b/vm/backend/src/com/codename1/impl/backend/Scheduler.java similarity index 96% rename from vm/backend/src/com/codename1/backend/Scheduler.java rename to vm/backend/src/com/codename1/impl/backend/Scheduler.java index 30073343a8a..292c9f0c3af 100644 --- a/vm/backend/src/com/codename1/backend/Scheduler.java +++ b/vm/backend/src/com/codename1/impl/backend/Scheduler.java @@ -20,7 +20,16 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; + +import com.codename1.backend.Crypto; + +import com.codename1.backend.Backend; +import com.codename1.backend.DataSource; +import com.codename1.backend.Span; +import com.codename1.backend.TaskExecutor; +import com.codename1.backend.Tracer; +import com.codename1.backend.Tracing; import java.io.IOException; import java.util.ArrayList; @@ -87,8 +96,8 @@ public static final class Job { this.initialDelay = initialDelay; // Named by kind when unnamed, for the reason Tasks.executor gives. this.executorName = executorName == null || executorName.length() == 0 - ? (threadKind == Tasks.VIRTUAL ? Tasks.SCHEDULING + "-virtual" - : Tasks.SCHEDULING) : executorName; + ? (threadKind == BackendAccess.VIRTUAL ? BackendAccess.SCHEDULING_EXECUTOR + "-virtual" + : BackendAccess.SCHEDULING_EXECUTOR) : executorName; this.threadKind = threadKind; this.lock = lock == null || lock.length() == 0 ? null : lock; this.lockAtMostFor = lockAtMostFor > 0 ? lockAtMostFor : DEFAULT_LOCK_MILLIS; @@ -136,11 +145,11 @@ public void setMeasured(boolean measured) { /// Takes the measuring and tracing of the server that runs this scheduler. /// Called by the generated application before it starts the scheduler. public synchronized void bind(Backend backend) { - this.measured = backend.isMeasured(); + this.measured = BackendAccess.get().isMeasured(backend); // Tracing off is the untraced marker, not null: null would hand the runs // to whichever tracer another server in the process installed. Tracer own = backend.getTracer(); - this.tracer = own != null ? own : Tracing.NONE; + this.tracer = own != null ? own : BackendAccess.get().untraced(); } private boolean lockTableReady; @@ -232,7 +241,7 @@ public synchronized void start() { long now = System.currentTimeMillis(); for (Object element : jobs) { Job job = (Job) element; - job.executor = Tasks.executor(job.executorName, job.threadKind); + job.executor = BackendAccess.get().executor(job.executorName, job.threadKind); if (job.kind == CRON) { job.next = job.cron.next(now); if (job.next < 0) { @@ -409,7 +418,7 @@ private void runJob(final Job job) { } if (job.lock == null || lease != null) { ran = true; - Tracing.inBackground("scheduled " + job.name, null, runTracer, + BackendAccess.get().inBackground("scheduled " + job.name, null, runTracer, new RunBody(job.body)); } } catch (Throwable err) { @@ -421,7 +430,7 @@ private void runJob(final Job job) { } long end = System.currentTimeMillis(); if (ran && measured) { - com.codename1.backend.metrics.Metrics.jobRan(job.name, end - start, + com.codename1.impl.backend.MetricsAccess.get().jobRan(job.name, end - start, error != null); } synchronized (this) { diff --git a/vm/backend/src/com/codename1/backend/orm/TransactionSession.java b/vm/backend/src/com/codename1/impl/backend/TransactionSession.java similarity index 97% rename from vm/backend/src/com/codename1/backend/orm/TransactionSession.java rename to vm/backend/src/com/codename1/impl/backend/TransactionSession.java index bb5770bc8fa..7231241dead 100644 --- a/vm/backend/src/com/codename1/backend/orm/TransactionSession.java +++ b/vm/backend/src/com/codename1/impl/backend/TransactionSession.java @@ -20,7 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.orm; +package com.codename1.impl.backend; + +import com.codename1.backend.orm.EntityManager; import com.codename1.backend.Transactions; import com.codename1.orm.session.JpqlQuery; @@ -51,7 +53,7 @@ public TransactionSession(EntityManager entities) { } private Session current() { - return Transactions.session(entities); + return BackendAccess.get().session(entities); } private static UnsupportedOperationException boundary(String what) { diff --git a/vm/backend/src/com/codename1/backend/Wiring.java b/vm/backend/src/com/codename1/impl/backend/Wiring.java similarity index 99% rename from vm/backend/src/com/codename1/backend/Wiring.java rename to vm/backend/src/com/codename1/impl/backend/Wiring.java index f59b9d16961..0fa59223b97 100644 --- a/vm/backend/src/com/codename1/backend/Wiring.java +++ b/vm/backend/src/com/codename1/impl/backend/Wiring.java @@ -20,7 +20,9 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend; +package com.codename1.impl.backend; + +import com.codename1.backend.Config; import java.io.IOException; import java.util.ArrayList; diff --git a/vm/backend/src/com/codename1/impl/backend/WiringEnvironment.java b/vm/backend/src/com/codename1/impl/backend/WiringEnvironment.java new file mode 100644 index 00000000000..685f240c55a --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/WiringEnvironment.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.metrics.Gauge; +import com.codename1.backend.orm.EntityManager; +import com.codename1.impl.backend.mcp.McpTool; +import java.util.ArrayList; +import java.util.List; + +/// What a [BackendApplication] is built from: the configuration, the database, +/// and the registrations the generated wiring makes while it builds the beans. +public final class WiringEnvironment { + private final Config config; + private final DataSource dataSource; + private final EntityManager entities; + + private final List tools; + private final List managed; + /// {name, description, unit, Gauge.Source}, for the server to add and later remove. + final List gauges = new ArrayList(); + + public WiringEnvironment(Config config, DataSource dataSource, EntityManager entities, + List tools, List managed) { + this.config = config; + this.dataSource = dataSource; + this.entities = entities; + this.tools = tools; + this.managed = managed; + } + + /// Publishes an `@McpTool` on this server's MCP endpoint. Generated + /// code calls this while it builds the beans; the tool belongs to this + /// server only, so a server started later in the same process does not + /// serve a tool bound to a bean that has been destroyed. + public void registerTool(McpTool tool) { + for (Object element : tools) { + if (((McpTool) element).name() + .equals(tool.name())) { + // Two active beans publishing one name: one would be + // unreachable, and which depends on construction order. + throw new IllegalStateException("Two MCP tools are named \"" + + tool.name() + "\"; give one a distinct name"); + } + } + tools.add(tool); + } + + /// Publishes a managed attribute as a gauge of this server's: added when + /// the server starts and removed when it stops. Generated code calls this. + public void registerGauge(String name, String description, String unit, + Gauge.Source source) { + gauges.add(new Object[] {name, description, unit, source}); + } + + /// Registers a managed bean with this server. Generated code calls this. + public void registerManaged(ManagedBean bean) { + String objectName = bean.getObjectName(); + if (objectName == null || objectName.length() == 0 || objectName.length() > 128) { + throw new IllegalArgumentException("A managed bean needs a name of 1 to 128 " + + "characters"); + } + for (int iter = 0 ; iter < objectName.length() ; iter++) { + char c = objectName.charAt(iter); + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_' || c == '.' || c == '-')) { + // One URL segment, matched undecoded; see the build's check. + throw new IllegalArgumentException("Managed bean \"" + objectName + + "\": a name is letters, digits, _, - and . only"); + } + } + for (Object element : managed) { + if (((ManagedBean) element).getObjectName() + .equals(bean.getObjectName())) { + throw new IllegalStateException("Two managed resources are named \"" + + bean.getObjectName() + "\"; set objectName on one"); + } + } + managed.add(bean); + } + + public Config getConfig() { + return config; + } + + /// The pool, or null when this server has no database. + public DataSource getDataSource() { + return dataSource; + } + + /// The entity manager, or null when the build generated no entities. + public EntityManager getEntityManager() { + return entities; + } + + /// The gauges registered so far, as {name, description, unit, Gauge.Source}, + /// for the server to add when it starts and remove when it stops. + public List gauges() { + return gauges; + } +} diff --git a/vm/backend/src/com/codename1/backend/mcp/DevTools.java b/vm/backend/src/com/codename1/impl/backend/mcp/DevTools.java similarity index 97% rename from vm/backend/src/com/codename1/backend/mcp/DevTools.java rename to vm/backend/src/com/codename1/impl/backend/mcp/DevTools.java index 4e1a9cc525d..a3a21a84072 100644 --- a/vm/backend/src/com/codename1/backend/mcp/DevTools.java +++ b/vm/backend/src/com/codename1/impl/backend/mcp/DevTools.java @@ -20,7 +20,12 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.mcp; +package com.codename1.impl.backend.mcp; + +import com.codename1.impl.backend.BackendAccess; +import com.codename1.impl.backend.DevConsole; +import com.codename1.impl.backend.Management; +import com.codename1.impl.backend.Scheduler; import java.io.IOException; import java.util.ArrayList; @@ -33,9 +38,6 @@ import com.codename1.backend.Config; import com.codename1.backend.DataSource; import com.codename1.backend.Database; -import com.codename1.backend.DevConsole; -import com.codename1.backend.Management; -import com.codename1.backend.Scheduler; import com.codename1.backend.Web; import com.codename1.backend.metrics.Metrics; import com.codename1.backend.orm.ColumnDefinition; @@ -96,7 +98,7 @@ public void install(McpServer server, Backend running) { new DevTools().install(server, running); return; } - running.getRequestLog().enable(200); + BackendAccess.get().enableRequestLog(running, 200); DevConsole.install(); server.register(new Tool("backend_routes", "Lists every HTTP route of the running backend: method, path and the " @@ -149,7 +151,7 @@ Object run(Map a) throws Exception { "failuresOnly", "boolean", "Only 5xx answers and exceptions"}, null)) { @Override Object run(Map a) { - return backend().getRequestLog().recent(intArg(a, "limit", 20), + return BackendAccess.get().recentRequests(backend(), intArg(a, "limit", 20), boolArg(a, "failuresOnly")); } }); @@ -225,7 +227,7 @@ Object run(Map a) { + "and their operations.", schema()) { @Override Object run(Map a) { - return Management.describeBeans(backend().getManagedBeans()); + return Management.describeBeans(BackendAccess.get().managedBeans(backend())); } }); server.register(new Tool("backend_invoke", @@ -237,14 +239,14 @@ Object run(Map a) { @Override Object run(Map a) throws Exception { Object args = a.get("arguments"); - return Management.invoke(backend().getManagedBeans(), stringArg(a, "bean"), stringArg(a, "operation"), + return Management.invoke(BackendAccess.get().managedBeans(backend()), stringArg(a, "bean"), stringArg(a, "operation"), args instanceof Map ? (Map) args : new LinkedHashMap()); } }); } - private Backend.Application application() { - return installed() == null ? null : backend().getApplication(); + private com.codename1.impl.backend.BackendApplication application() { + return installed() == null ? null : BackendAccess.get().applicationOf(backend()); } private Map config() throws Exception { diff --git a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java b/vm/backend/src/com/codename1/impl/backend/mcp/McpArgs.java similarity index 99% rename from vm/backend/src/com/codename1/backend/mcp/McpArgs.java rename to vm/backend/src/com/codename1/impl/backend/mcp/McpArgs.java index 8d83ef66245..a13e35be5dd 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpArgs.java +++ b/vm/backend/src/com/codename1/impl/backend/mcp/McpArgs.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.mcp; +package com.codename1.impl.backend.mcp; import java.util.ArrayList; import java.util.LinkedHashMap; diff --git a/vm/backend/src/com/codename1/backend/mcp/McpServer.java b/vm/backend/src/com/codename1/impl/backend/mcp/McpServer.java similarity index 99% rename from vm/backend/src/com/codename1/backend/mcp/McpServer.java rename to vm/backend/src/com/codename1/impl/backend/mcp/McpServer.java index 4feeff8873d..787beeeb98b 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpServer.java +++ b/vm/backend/src/com/codename1/impl/backend/mcp/McpServer.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.mcp; +package com.codename1.impl.backend.mcp; import java.io.IOException; import java.util.ArrayList; diff --git a/vm/backend/src/com/codename1/backend/mcp/McpTool.java b/vm/backend/src/com/codename1/impl/backend/mcp/McpTool.java similarity index 98% rename from vm/backend/src/com/codename1/backend/mcp/McpTool.java rename to vm/backend/src/com/codename1/impl/backend/mcp/McpTool.java index dd675f13075..d76cb711b43 100644 --- a/vm/backend/src/com/codename1/backend/mcp/McpTool.java +++ b/vm/backend/src/com/codename1/impl/backend/mcp/McpTool.java @@ -20,7 +20,7 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.mcp; +package com.codename1.impl.backend.mcp; import java.util.Map; diff --git a/vm/backend/src/com/codename1/backend/mcp/package-info.java b/vm/backend/src/com/codename1/impl/backend/mcp/package-info.java similarity index 97% rename from vm/backend/src/com/codename1/backend/mcp/package-info.java rename to vm/backend/src/com/codename1/impl/backend/mcp/package-info.java index 10987559cba..2d37bca2c6d 100644 --- a/vm/backend/src/com/codename1/backend/mcp/package-info.java +++ b/vm/backend/src/com/codename1/impl/backend/mcp/package-info.java @@ -31,4 +31,4 @@ /// jobs and metrics -- and is installed only on a development profile. /// /// Server code: this package is not available in the app. -package com.codename1.backend.mcp; +package com.codename1.impl.backend.mcp; diff --git a/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java b/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java index cdb176cf61e..db8562a022b 100644 --- a/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java +++ b/vm/backend/src/com/codename1/impl/orm/BackendSqlAccess.java @@ -23,7 +23,6 @@ package com.codename1.impl.orm; import com.codename1.backend.Crypto; -import com.codename1.backend.Transactions; import com.codename1.backend.Database; import com.codename1.backend.DataSource; import com.codename1.backend.sql.Dialect; @@ -149,7 +148,7 @@ public String limit(int count, int offset) { private Database connection() throws IOException { Database db = transaction != null ? transaction : supplied != null ? supplied : pool.borrow(); if (db != transaction && db.isInTransaction() //NOPMD CompareObjectsWithEquals - connection identity - && !(pool != null && Transactions.isJoined(pool, db))) { + && !(pool != null && com.codename1.impl.backend.BackendAccess.get().isJoined(pool, db))) { if (db != supplied) { //NOPMD CompareObjectsWithEquals - connection identity db.close(); pool.release(db); @@ -264,7 +263,7 @@ public void begin() throws IOException { if (supplied == null && pool != null) { // Inside a @Transactional method: this session becomes part of that // transaction instead of opening one the connection would refuse. - Database joined = Transactions.joined(pool); + Database joined = com.codename1.impl.backend.BackendAccess.get().joined(pool); if (joined != null) { transaction = joined; joinedTransaction = true; @@ -307,7 +306,7 @@ public void rollback() throws IOException { throw new IOException("No transaction"); } if (joinedTransaction) { - Transactions.markRollbackOnly(pool); + com.codename1.impl.backend.BackendAccess.get().markRollbackOnly(pool); unpin(); return; } @@ -327,7 +326,7 @@ public void close() throws IOException { if (transaction != null && joinedTransaction) { // Closed without committing: its changes were never flushed, and the // method's transaction cannot commit as though they had been. - Transactions.markRollbackOnly(pool); + com.codename1.impl.backend.BackendAccess.get().markRollbackOnly(pool); unpin(); return; } diff --git a/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java b/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java new file mode 100644 index 00000000000..baebe6cc43c --- /dev/null +++ b/vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestInstancePostProcessor; + +/// The JUnit 5 side of `@BackendTest`: finds the context the build generated for +/// the test class, starts its application (or reuses the running one), fills the +/// test's fields, and resets its mocks after each test. +/// +/// The context is found by its generated name -- `Cn1TestContext`, with +/// a nested class's `$` written `_` -- which this, the JVM half, may do by +/// reflection. The compiled half has no reflection and no JUnit; the build writes +/// the same calls into a generated runner instead. +public final class BackendTestExtension implements TestInstancePostProcessor, AfterEachCallback { + private static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(BackendTestExtension.class); + private static boolean hookInstalled; + + @Override + public void postProcessTestInstance(Object testInstance, ExtensionContext extension) + throws Exception { + TestContext context = load(testInstance.getClass()); + TestEnvironment environment = TestContexts.acquire(context); + installShutdownHook(); + context.inject(testInstance, environment); + extension.getStore(NAMESPACE).put("environment", environment); + } + + @Override + public void afterEach(ExtensionContext extension) throws Exception { + Object stored = extension.getStore(NAMESPACE).get("environment"); + if (!(stored instanceof TestEnvironment)) { + return; + } + TestEnvironment environment = (TestEnvironment) stored; + String[] mocks = environment.context().mockBeans(); + for (String value : mocks) { + Mocks.reset(environment.bean(value)); + } + } + + /// The generated context of `testClass`. + static TestContext load(Class testClass) throws Exception { + String name = contextName(testClass.getName()); + Class generated; + try { + generated = Class.forName(name, true, testClass.getClassLoader()); + } catch (ClassNotFoundException missing) { + throw new IllegalStateException("No generated test context " + name + " for " + + testClass.getName() + ". The build generates it in the " + + "process-test-classes phase: bind the codenameone-maven-plugin goal " + + "process-test-annotations in this module's pom.", missing); + } + Object instance = generated.getDeclaredConstructor().newInstance(); + if (!(instance instanceof TestContext)) { + throw new IllegalStateException(name + " is not a generated test context"); + } + return (TestContext) instance; + } + + /// The generated context's binary name for a test class's binary name. + static String contextName(String testClass) { + int dot = testClass.lastIndexOf('.'); + String pkg = dot < 0 ? "" : testClass.substring(0, dot + 1); + return pkg + testClass.substring(dot + 1).replace('$', '_') + "Cn1TestContext"; + } + + private static synchronized void installShutdownHook() { + if (hookInstalled) { + return; + } + hookInstalled = true; + Runtime.getRuntime().addShutdownHook(new Thread("cn1-backend-test-shutdown") { + @Override + public void run() { + TestContexts.shutdown(); + } + }); + } +} diff --git a/vm/backend/test/javase/com/codename1/impl/backend/test/Mocks.java b/vm/backend/test/javase/com/codename1/impl/backend/test/Mocks.java new file mode 100644 index 00000000000..7e05ac468b9 --- /dev/null +++ b/vm/backend/test/javase/com/codename1/impl/backend/test/Mocks.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +import java.lang.reflect.Method; + +/// `@MockitoBean`'s mocks, through Mockito -- reached by reflection, so this jar +/// does not depend on Mockito and a project that mocks nothing needs none. +public final class Mocks { + private Mocks() { + } + + /// A Mockito mock of `type`. + public static Object create(Class type) { + try { + Method mock = mockito().getMethod("mock", Class.class); + return mock.invoke(null, type); + } catch (ReflectiveOperationException err) { + throw new IllegalStateException("Could not mock " + type.getName() + ": " + err, err); + } + } + + /// Clears a mock's stubbing and recorded calls, between tests. + public static void reset(Object mock) { + if (mock == null) { + return; + } + try { + Method reset = mockito().getMethod("reset", Object[].class); + reset.invoke(null, new Object[] {new Object[] {mock}}); + } catch (ReflectiveOperationException err) { + throw new IllegalStateException("Could not reset a mock: " + err, err); + } + } + + private static Class mockito() { + try { + return Class.forName("org.mockito.Mockito"); + } catch (ClassNotFoundException missing) { + throw new IllegalStateException("@MockitoBean needs Mockito on the test classpath: " + + "add org.mockito:mockito-core with test scope.", missing); + } + } +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/AfterAll.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/AfterAll.java new file mode 100644 index 00000000000..e537a7576d1 --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/AfterAll.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A static method that runs once after the tests of its class. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface AfterAll { +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/AfterEach.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/AfterEach.java new file mode 100644 index 00000000000..d48f011a718 --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/AfterEach.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Runs after each test method of its class, whether it passed or not. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface AfterEach { +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java new file mode 100644 index 00000000000..800e95b48be --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java @@ -0,0 +1,818 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.util.Iterator; +import java.util.function.Supplier; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.function.ThrowingSupplier; +import org.opentest4j.AssertionFailedError; + +/// Assertions for compiled backend tests. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against; see `Test` for why it exists. The messages follow JUnit's shape -- +/// `message ==> expected: but was: ` -- so a report reads the same from +/// either runner. +public final class Assertions { + private Assertions() { + } + + // ------------------------------------------------------------------ fail + + public static V fail() { + throw new AssertionFailedError(); + } + + public static V fail(String message) { + throw new AssertionFailedError(message); + } + + public static V fail(String message, Throwable cause) { + throw new AssertionFailedError(message, cause); + } + + public static V fail(Throwable cause) { + throw new AssertionFailedError(cause == null ? null : cause.toString(), cause); + } + + public static V fail(Supplier message) { + throw new AssertionFailedError(text(message)); + } + + // ------------------------------------------------------------ true/false + + public static void assertTrue(boolean condition) { + assertTrue(condition, (String) null); + } + + public static void assertTrue(boolean condition, String message) { + if (!condition) { + throw new AssertionFailedError(prefix(message) + "expected: but was: ", + Boolean.TRUE, Boolean.FALSE); + } + } + + public static void assertTrue(boolean condition, Supplier message) { + if (!condition) { + assertTrue(false, text(message)); + } + } + + public static void assertFalse(boolean condition) { + assertFalse(condition, (String) null); + } + + public static void assertFalse(boolean condition, String message) { + if (condition) { + throw new AssertionFailedError(prefix(message) + "expected: but was: ", + Boolean.FALSE, Boolean.TRUE); + } + } + + public static void assertFalse(boolean condition, Supplier message) { + if (condition) { + assertFalse(true, text(message)); + } + } + + // ------------------------------------------------------------------ null + + public static void assertNull(Object actual) { + assertNull(actual, (String) null); + } + + public static void assertNull(Object actual, String message) { + if (actual != null) { + throw new AssertionFailedError(prefix(message) + "expected: but was: <" + + actual + ">", null, actual); + } + } + + public static void assertNull(Object actual, Supplier message) { + if (actual != null) { + assertNull(actual, text(message)); + } + } + + public static void assertNotNull(Object actual) { + assertNotNull(actual, (String) null); + } + + public static void assertNotNull(Object actual, String message) { + if (actual == null) { + throw new AssertionFailedError(prefix(message) + "expected: not "); + } + } + + public static void assertNotNull(Object actual, Supplier message) { + if (actual == null) { + assertNotNull(null, text(message)); + } + } + + // ---------------------------------------------------------------- equals + + public static void assertEquals(Object expected, Object actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(Object expected, Object actual, String message) { + if (!same(expected, actual)) { + notEqual(expected, actual, message); + } + } + + public static void assertEquals(Object expected, Object actual, Supplier message) { + if (!same(expected, actual)) { + notEqual(expected, actual, text(message)); + } + } + + public static void assertEquals(byte expected, byte actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(byte expected, byte actual, String message) { + if (expected != actual) { + notEqual(Byte.valueOf(expected), Byte.valueOf(actual), message); + } + } + + public static void assertEquals(byte expected, byte actual, Supplier message) { + if (expected != actual) { + notEqual(Byte.valueOf(expected), Byte.valueOf(actual), text(message)); + } + } + + public static void assertEquals(short expected, short actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(short expected, short actual, String message) { + if (expected != actual) { + notEqual(Short.valueOf(expected), Short.valueOf(actual), message); + } + } + + public static void assertEquals(short expected, short actual, Supplier message) { + if (expected != actual) { + notEqual(Short.valueOf(expected), Short.valueOf(actual), text(message)); + } + } + + public static void assertEquals(int expected, int actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(int expected, int actual, String message) { + if (expected != actual) { + notEqual(Integer.valueOf(expected), Integer.valueOf(actual), message); + } + } + + public static void assertEquals(int expected, int actual, Supplier message) { + if (expected != actual) { + notEqual(Integer.valueOf(expected), Integer.valueOf(actual), text(message)); + } + } + + public static void assertEquals(long expected, long actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(long expected, long actual, String message) { + if (expected != actual) { + notEqual(Long.valueOf(expected), Long.valueOf(actual), message); + } + } + + public static void assertEquals(long expected, long actual, Supplier message) { + if (expected != actual) { + notEqual(Long.valueOf(expected), Long.valueOf(actual), text(message)); + } + } + + public static void assertEquals(char expected, char actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(char expected, char actual, String message) { + if (expected != actual) { + notEqual(Character.valueOf(expected), Character.valueOf(actual), message); + } + } + + public static void assertEquals(char expected, char actual, Supplier message) { + if (expected != actual) { + notEqual(Character.valueOf(expected), Character.valueOf(actual), text(message)); + } + } + + public static void assertEquals(float expected, float actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(float expected, float actual, String message) { + // Box comparison, as JUnit does: NaN equals NaN, and 0.0 is not -0.0. + if (!Float.valueOf(expected).equals(Float.valueOf(actual))) { + notEqual(Float.valueOf(expected), Float.valueOf(actual), message); + } + } + + public static void assertEquals(float expected, float actual, Supplier message) { + if (!Float.valueOf(expected).equals(Float.valueOf(actual))) { + notEqual(Float.valueOf(expected), Float.valueOf(actual), text(message)); + } + } + + public static void assertEquals(float expected, float actual, float delta) { + assertEquals(expected, actual, delta, (String) null); + } + + public static void assertEquals(float expected, float actual, float delta, String message) { + checkDelta(delta); + if (!Float.valueOf(expected).equals(Float.valueOf(actual)) + && !(Math.abs(expected - actual) <= delta)) { + notEqual(Float.valueOf(expected), Float.valueOf(actual), message); + } + } + + public static void assertEquals(double expected, double actual) { + assertEquals(expected, actual, (String) null); + } + + public static void assertEquals(double expected, double actual, String message) { + // Box comparison, as JUnit does: NaN equals NaN, and 0.0 is not -0.0. + if (!Double.valueOf(expected).equals(Double.valueOf(actual))) { + notEqual(Double.valueOf(expected), Double.valueOf(actual), message); + } + } + + public static void assertEquals(double expected, double actual, Supplier message) { + if (!Double.valueOf(expected).equals(Double.valueOf(actual))) { + notEqual(Double.valueOf(expected), Double.valueOf(actual), text(message)); + } + } + + public static void assertEquals(double expected, double actual, double delta) { + assertEquals(expected, actual, delta, (String) null); + } + + public static void assertEquals(double expected, double actual, double delta, String message) { + checkDelta(delta); + if (!Double.valueOf(expected).equals(Double.valueOf(actual)) + && !(Math.abs(expected - actual) <= delta)) { + notEqual(Double.valueOf(expected), Double.valueOf(actual), message); + } + } + + public static void assertNotEquals(Object unexpected, Object actual) { + assertNotEquals(unexpected, actual, (String) null); + } + + public static void assertNotEquals(Object unexpected, Object actual, String message) { + if (same(unexpected, actual)) { + throw new AssertionFailedError(prefix(message) + "expected: not equal but was: <" + + actual + ">"); + } + } + + public static void assertNotEquals(long unexpected, long actual) { + assertNotEquals(Long.valueOf(unexpected), Long.valueOf(actual), (String) null); + } + + public static void assertNotEquals(long unexpected, long actual, String message) { + assertNotEquals(Long.valueOf(unexpected), Long.valueOf(actual), message); + } + + public static void assertNotEquals(double unexpected, double actual) { + assertNotEquals(Double.valueOf(unexpected), Double.valueOf(actual), (String) null); + } + + public static void assertNotEquals(double unexpected, double actual, String message) { + assertNotEquals(Double.valueOf(unexpected), Double.valueOf(actual), message); + } + + // ------------------------------------------------------------------ same + + public static void assertSame(Object expected, Object actual) { + assertSame(expected, actual, (String) null); + } + + public static void assertSame(Object expected, Object actual, String message) { + if (expected != actual) { + throw new AssertionFailedError(prefix(message) + "expected: same as <" + expected + + "> but was: <" + actual + ">", expected, actual); + } + } + + public static void assertNotSame(Object unexpected, Object actual) { + assertNotSame(unexpected, actual, (String) null); + } + + public static void assertNotSame(Object unexpected, Object actual, String message) { + if (unexpected == actual) { + throw new AssertionFailedError(prefix(message) + "expected: not same but was: <" + + actual + ">"); + } + } + + // ---------------------------------------------------------------- arrays + + public static void assertArrayEquals(byte[] expected, byte[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(byte[] expected, byte[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(short[] expected, short[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(short[] expected, short[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(int[] expected, int[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(int[] expected, int[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(long[] expected, long[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(long[] expected, long[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(char[] expected, char[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(char[] expected, char[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(float[] expected, float[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(float[] expected, float[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(double[] expected, double[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(double[] expected, double[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(boolean[] expected, boolean[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(boolean[] expected, boolean[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + if (!String.valueOf(expected[iter]).equals(String.valueOf(actual[iter]))) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + expected[iter] + "> but was: <" + + actual[iter] + ">"); + } + } + } + + public static void assertArrayEquals(Object[] expected, Object[] actual) { + assertArrayEquals(expected, actual, (String) null); + } + + public static void assertArrayEquals(Object[] expected, Object[] actual, String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + if (expected.length != actual.length) { + throw new AssertionFailedError(prefix(message) + "array lengths differ, expected: <" + + expected.length + "> but was: <" + actual.length + ">"); + } + for (int iter = 0 ; iter < expected.length ; iter++) { + Object e = expected[iter]; + Object a = actual[iter]; + if (e instanceof Object[] && a instanceof Object[]) { + assertArrayEquals((Object[]) e, (Object[]) a, message); + } else if (!nestedPrimitiveArrays(e, a, message) && !same(e, a)) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + e + "> but was: <" + a + ">"); + } + } + } + + /// Compares two elements of an outer array that are primitive arrays of one + /// type -- the rows of an `int[][]` -- as JUnit does, by content rather than + /// identity; false when they are not, for the caller to compare otherwise. + private static boolean nestedPrimitiveArrays(Object e, Object a, String message) { + if (e instanceof int[] && a instanceof int[]) { + assertArrayEquals((int[]) e, (int[]) a, message); + } else if (e instanceof long[] && a instanceof long[]) { + assertArrayEquals((long[]) e, (long[]) a, message); + } else if (e instanceof byte[] && a instanceof byte[]) { + assertArrayEquals((byte[]) e, (byte[]) a, message); + } else if (e instanceof short[] && a instanceof short[]) { + assertArrayEquals((short[]) e, (short[]) a, message); + } else if (e instanceof char[] && a instanceof char[]) { + assertArrayEquals((char[]) e, (char[]) a, message); + } else if (e instanceof float[] && a instanceof float[]) { + assertArrayEquals((float[]) e, (float[]) a, message); + } else if (e instanceof double[] && a instanceof double[]) { + assertArrayEquals((double[]) e, (double[]) a, message); + } else if (e instanceof boolean[] && a instanceof boolean[]) { + assertArrayEquals((boolean[]) e, (boolean[]) a, message); + } else { + return false; + } + return true; + } + + public static void assertIterableEquals(Iterable expected, Iterable actual) { + assertIterableEquals(expected, actual, (String) null); + } + + public static void assertIterableEquals(Iterable expected, Iterable actual, + String message) { + if (expected == actual) { + return; + } + if (expected == null || actual == null) { + notEqual(expected, actual, message); + } + Iterator e = expected.iterator(); + Iterator a = actual.iterator(); + int index = 0; + while (e.hasNext() && a.hasNext()) { + Object x = e.next(); + Object y = a.next(); + if (x != y && x instanceof Iterable && y instanceof Iterable) { + // Nested iterables compare by iteration, as JUnit's do: a List and + // a Set holding the same elements in order are equal here. + assertIterableEquals((Iterable) x, (Iterable) y, message); + } else if (!same(x, y)) { + throw new AssertionFailedError(prefix(message) + "iterable contents differ at index [" + + index + "], expected: <" + x + "> but was: <" + y + ">"); + } + index++; + } + if (e.hasNext() || a.hasNext()) { + throw new AssertionFailedError(prefix(message) + "iterable lengths differ at index [" + + index + "]"); + } + } + + // ---------------------------------------------------------------- throws + + public static T assertThrows(Class expectedType, Executable executable) { + return assertThrows(expectedType, executable, (String) null); + } + + public static T assertThrows(Class expectedType, Executable executable, + String message) { + try { + executable.execute(); + } catch (Throwable actual) { + if (expectedType.isInstance(actual)) { + @SuppressWarnings("unchecked") + T matched = (T) actual; + return matched; + } + rethrowIfUnrecoverable(actual); + throw new AssertionFailedError(prefix(message) + "Unexpected exception type thrown, " + + "expected: <" + expectedType.getName() + "> but was: <" + + actual.getClass().getName() + ">", actual); + } + throw new AssertionFailedError(prefix(message) + "Expected " + expectedType.getName() + + " to be thrown, but nothing was thrown."); + } + + public static T assertThrows(Class expectedType, Executable executable, + Supplier message) { + // The message is built only for a failure, as JUnit's is: a passing + // assertion must not depend on the supplier, nor run its side effects. + try { + executable.execute(); + } catch (Throwable actual) { + if (expectedType.isInstance(actual)) { + @SuppressWarnings("unchecked") + T matched = (T) actual; + return matched; + } + rethrowIfUnrecoverable(actual); + throw new AssertionFailedError(prefix(text(message)) + "Unexpected exception type " + + "thrown, expected: <" + expectedType.getName() + "> but was: <" + + actual.getClass().getName() + ">", actual); + } + throw new AssertionFailedError(prefix(text(message)) + "Expected " + expectedType.getName() + + " to be thrown, but nothing was thrown."); + } + + public static T assertThrowsExactly(Class expectedType, + Executable executable) { + return assertThrowsExactly(expectedType, executable, (String) null); + } + + public static T assertThrowsExactly(Class expectedType, + Executable executable, String message) { + Throwable actual = null; + try { + executable.execute(); + } catch (Throwable thrown) { + actual = thrown; + } + if (actual == null) { + throw new AssertionFailedError(prefix(message) + "Expected " + expectedType.getName() + + " to be thrown, but nothing was thrown."); + } + if (actual.getClass() != expectedType) { + rethrowIfUnrecoverable(actual); + throw new AssertionFailedError(prefix(message) + "Unexpected exception type thrown, " + + "expected: <" + expectedType.getName() + "> but was: <" + + actual.getClass().getName() + ">", actual); + } + @SuppressWarnings("unchecked") + T matched = (T) actual; + return matched; + } + + public static void assertDoesNotThrow(Executable executable) { + assertDoesNotThrow(executable, (String) null); + } + + public static void assertDoesNotThrow(Executable executable, String message) { + try { + executable.execute(); + } catch (Throwable thrown) { + rethrowIfUnrecoverable(thrown); + throw new AssertionFailedError(prefix(message) + "Unexpected exception thrown: " + + thrown, thrown); + } + } + + public static T assertDoesNotThrow(ThrowingSupplier supplier) { + return assertDoesNotThrow(supplier, (String) null); + } + + public static T assertDoesNotThrow(ThrowingSupplier supplier, String message) { + try { + return supplier.get(); + } catch (Throwable thrown) { + rethrowIfUnrecoverable(thrown); + throw new AssertionFailedError(prefix(message) + "Unexpected exception thrown: " + + thrown, thrown); + } + } + + public static T assertInstanceOf(Class expectedType, Object actual) { + return assertInstanceOf(expectedType, actual, (String) null); + } + + public static T assertInstanceOf(Class expectedType, Object actual, String message) { + if (!expectedType.isInstance(actual)) { + throw new AssertionFailedError(prefix(message) + "Unexpected type, expected: <" + + expectedType.getName() + "> but was: <" + + (actual == null ? "null" : actual.getClass().getName()) + ">"); + } + @SuppressWarnings("unchecked") + T matched = (T) actual; + return matched; + } + + // ------------------------------------------------------------------- all + + public static void assertAll(Executable... executables) { + assertAll(null, executables); + } + + public static void assertAll(String heading, Executable... executables) { + StringBuilder failures = null; + int count = 0; + Throwable first = null; + for (int iter = 0 ; iter < executables.length ; iter++) { + try { + executables[iter].execute(); + } catch (OutOfMemoryError unrecoverable) { + // Rethrown at once, as JUnit does: running the rest after it is + // unsafe and the report would hide what really failed. + throw unrecoverable; + } catch (Throwable thrown) { + if (first == null) { + first = thrown; + failures = new StringBuilder(); + } + count++; + failures.append("\n\t").append(thrown.getMessage() == null ? thrown.toString() + : thrown.getMessage()); + } + } + if (first != null) { + throw new AssertionFailedError((heading == null ? "Multiple Failures" : heading) + + " (" + count + " failure" + (count == 1 ? "" : "s") + ")" + failures, first); + } + } + + // --------------------------------------------------------------- helpers + + /// Refuses a negative or NaN delta before comparing, with JUnit 5.9's own + /// failure (measured: an AssertionFailedError, "positive delta expected but + /// was: <-1.0>"). Unchecked, equal operands passed and unequal ones reported a + /// mismatch, where the JVM run fails on the delta itself. + private static void checkDelta(double delta) { + if (Double.isNaN(delta) || delta < 0.0) { + throw new AssertionFailedError("positive delta expected but was: <" + delta + ">"); + } + } + + /// Rethrows an OutOfMemoryError unchanged, as JUnit's UnrecoverableExceptions + /// does wherever an assertion catches what it runs: wrapping it would allocate + /// while memory is gone and hide that the VM is compromised. + private static void rethrowIfUnrecoverable(Throwable thrown) { + if (thrown instanceof OutOfMemoryError) { + throw (OutOfMemoryError) thrown; + } + } + + private static boolean same(Object expected, Object actual) { + return expected == actual || (expected != null && expected.equals(actual)); + } + + private static void notEqual(Object expected, Object actual, String message) { + String e = String.valueOf(expected); + String a = String.valueOf(actual); + if (e.equals(a) && expected != null && actual != null) { + // Equal text, different types: say so, as JUnit does. + e = expected.getClass().getName() + "@<" + e + ">"; + a = actual.getClass().getName() + "@<" + a + ">"; + throw new AssertionFailedError(prefix(message) + "expected: " + e + " but was: " + a, + expected, actual); + } + throw new AssertionFailedError(prefix(message) + "expected: <" + e + "> but was: <" + a + + ">", expected, actual); + } + + private static String prefix(String message) { + return message == null || message.length() == 0 ? "" : message + " ==> "; + } + + private static String text(Supplier message) { + return message == null ? null : message.get(); + } +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/Assumptions.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/Assumptions.java new file mode 100644 index 00000000000..659cb3ffabd --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Assumptions.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.util.function.Supplier; +import org.opentest4j.TestAbortedException; + +/// Assumptions for compiled backend tests: a test whose assumption does not hold is +/// reported as skipped, not failed. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against; see `Test` for why it exists. +public final class Assumptions { + private Assumptions() { + } + + public static void assumeTrue(boolean assumption) { + assumeTrue(assumption, (String) null); + } + + public static void assumeTrue(boolean assumption, String message) { + if (!assumption) { + throw new TestAbortedException("Assumption failed: " + + (message == null ? "assumption is not true" : message)); + } + } + + public static void assumeTrue(boolean assumption, Supplier message) { + if (!assumption) { + assumeTrue(false, message == null ? null : message.get()); + } + } + + public static void assumeFalse(boolean assumption) { + assumeFalse(assumption, (String) null); + } + + public static void assumeFalse(boolean assumption, String message) { + if (assumption) { + throw new TestAbortedException("Assumption failed: " + + (message == null ? "assumption is not false" : message)); + } + } + + public static void assumeFalse(boolean assumption, Supplier message) { + if (assumption) { + assumeFalse(true, message == null ? null : message.get()); + } + } + + public static V abort(String message) { + throw new TestAbortedException(message); + } +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeAll.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeAll.java new file mode 100644 index 00000000000..8feab5411eb --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeAll.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A static method that runs once before the tests of its class. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface BeforeAll { +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeEach.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeEach.java new file mode 100644 index 00000000000..ec5f96ebe34 --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeEach.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Runs before each test method of its class. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface BeforeEach { +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Service.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/Disabled.java similarity index 68% rename from vm/backend/src/com/codename1/backend/annotations/Service.java rename to vm/backend/test/junit-shim/org/junit/jupiter/api/Disabled.java index 471967c9cdc..283861bf5ce 100644 --- a/vm/backend/src/com/codename1/backend/annotations/Service.java +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Disabled.java @@ -20,18 +20,23 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package org.junit.jupiter.api; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// A [Component] in the service layer. Identical to it in every respect except -/// the word, which is there for the reader. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface Service { - /// The bean's name, for [Qualifier]. Empty means the default name. +/// Skips a test method, or every test of a class. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Disabled { + /// Why, for the report. String value() default ""; } diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/DisplayName.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/DisplayName.java new file mode 100644 index 00000000000..ac58dfc9b33 --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/DisplayName.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The name a test is reported under. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface DisplayName { + /// The name the report shows. + String value(); +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/Tag.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/Tag.java new file mode 100644 index 00000000000..44cdf0e13a2 --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Tag.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A tag for selecting tests; recorded and otherwise ignored here. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface Tag { + /// The tag. + String value(); +} diff --git a/vm/backend/src/com/codename1/backend/annotations/Repository.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/Test.java similarity index 70% rename from vm/backend/src/com/codename1/backend/annotations/Repository.java rename to vm/backend/test/junit-shim/org/junit/jupiter/api/Test.java index 84f5027c31b..20d6cbc0527 100644 --- a/vm/backend/src/com/codename1/backend/annotations/Repository.java +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Test.java @@ -20,18 +20,21 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package org.junit.jupiter.api; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// A [Component] in the persistence layer. Identical to it in every respect -/// except the word, which is there for the reader. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface Repository { - /// The bean's name, for [Qualifier]. Empty means the default name. - String value() default ""; +/// A test method. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface Test { } diff --git a/vm/backend/src/com/codename1/backend/annotations/ServerConfig.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/extension/ExtendWith.java similarity index 62% rename from vm/backend/src/com/codename1/backend/annotations/ServerConfig.java rename to vm/backend/test/junit-shim/org/junit/jupiter/api/extension/ExtendWith.java index a10c375642c..95c4930b396 100644 --- a/vm/backend/src/com/codename1/backend/annotations/ServerConfig.java +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/extension/ExtendWith.java @@ -20,31 +20,24 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package org.junit.jupiter.api.extension; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Listener settings, compiled in. +/// Registers extensions on a test class. Compiled tests ignore it: what an +/// extension does there is generated by the build instead. /// -/// Each attribute is the `cn1.server.*` key named beside it, and sets that key's -/// value at the bottom of the configuration, so `PORT`, a properties file or the -/// environment still override it. An attribute left at its default sets nothing. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface ServerConfig { - /// `cn1.server.port`. - int port() default -1; - - /// `cn1.server.workers`: the request thread pool. - int workers() default -1; - - /// `cn1.server.backlog`: the listen backlog. - int backlog() default -1; - - /// `cn1.server.shutdownTimeoutMillis`: how long a stop waits for the requests - /// in flight. - int shutdownTimeoutMillis() default -1; +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +public @interface ExtendWith { + /// The extensions. + Class[] value(); } diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/extension/Extension.java similarity index 69% rename from docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java rename to vm/backend/test/junit-shim/org/junit/jupiter/api/extension/Extension.java index ce2a9a9e09a..035ccbfa418 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/extension/Extension.java @@ -20,17 +20,14 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codenameone.developerguide.backend.beans; +package org.junit.jupiter.api.extension; -import com.codename1.backend.annotations.Configuration; -import com.codename1.backend.annotations.ServerConfig; -import com.codename1.backend.annotations.SessionConfig; - -/** The Backend chapter's settings-annotation example, compiled so it cannot drift. */ -// tag::backend-settings-annotations[] -@Configuration -@ServerConfig(port = 8080, workers = 32) -@SessionConfig(store = "db", timeoutSeconds = 3600) -public class AppSettings { +/// The marker every extension implements. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +public interface Extension { } -// end::backend-settings-annotations[] diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/function/Executable.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/function/Executable.java new file mode 100644 index 00000000000..aee9717c9fb --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/function/Executable.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api.function; + +/// A block of code an assertion runs, such as the one `assertThrows` expects to throw. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +public interface Executable { + /// Runs the block. + void execute() throws Throwable; +} diff --git a/vm/backend/test/junit-shim/org/junit/jupiter/api/function/ThrowingSupplier.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/function/ThrowingSupplier.java new file mode 100644 index 00000000000..0fee2c29fb0 --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/function/ThrowingSupplier.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.junit.jupiter.api.function; + +/// A value an assertion computes and returns, such as `assertDoesNotThrow`'s. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +public interface ThrowingSupplier { + /// Computes the value. + T get() throws Throwable; +} diff --git a/vm/backend/src/com/codename1/backend/annotations/RequestScope.java b/vm/backend/test/junit-shim/org/junit/jupiter/api/parallel/ResourceLock.java similarity index 77% rename from vm/backend/src/com/codename1/backend/annotations/RequestScope.java rename to vm/backend/test/junit-shim/org/junit/jupiter/api/parallel/ResourceLock.java index fab81182389..311bc0889d2 100644 --- a/vm/backend/src/com/codename1/backend/annotations/RequestScope.java +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/parallel/ResourceLock.java @@ -20,15 +20,22 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package org.junit.jupiter.api.parallel; +import java.lang.annotation.Documented; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Shorthand for `@Scope("request")`. -@Retention(RetentionPolicy.CLASS) +/// A shared resource a test class uses, so JUnit does not run two classes that +/// need it at the same time. Inert in a compiled run, which runs one class at a +/// time anyway. +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) -public @interface RequestScope { +public @interface ResourceLock { + String value(); } diff --git a/vm/backend/test/junit-shim/org/opentest4j/AssertionFailedError.java b/vm/backend/test/junit-shim/org/opentest4j/AssertionFailedError.java new file mode 100644 index 00000000000..aae5b967d83 --- /dev/null +++ b/vm/backend/test/junit-shim/org/opentest4j/AssertionFailedError.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.opentest4j; + +/// A failed assertion, with what was expected and what was found. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +public class AssertionFailedError extends AssertionError { + private final Object expected; + private final Object actual; + private final boolean values; + + public AssertionFailedError() { + this(null); + } + + public AssertionFailedError(String message) { + super(message); + this.expected = null; + this.actual = null; + this.values = false; + } + + public AssertionFailedError(String message, Object expected, Object actual) { + super(message); + this.expected = expected; + this.actual = actual; + this.values = true; + } + + public AssertionFailedError(String message, Throwable cause) { + super(message); + initCause(cause); + this.expected = null; + this.actual = null; + this.values = false; + } + + /// Whether the failure carries an expected and an actual value. + public boolean isExpectedDefined() { + return values; + } + + public Object getExpected() { + return expected; + } + + public Object getActual() { + return actual; + } +} diff --git a/vm/backend/test/junit-shim/org/opentest4j/TestAbortedException.java b/vm/backend/test/junit-shim/org/opentest4j/TestAbortedException.java new file mode 100644 index 00000000000..f2652baf339 --- /dev/null +++ b/vm/backend/test/junit-shim/org/opentest4j/TestAbortedException.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package org.opentest4j; + +/// A test that stopped because an assumption did not hold: reported as skipped, +/// not failed. +/// +/// Part of the subset of JUnit 5's API that a compiled backend test translates +/// against. On the JVM the real JUnit runs the same source; under ParparVM there is +/// no JUnit, no reflection and no annotation reading at run time, so the build +/// finds the tests from their bytecode and generates the calls, and these classes +/// only have to compile and to fail the way JUnit's do. Never on a JVM classpath. +public class TestAbortedException extends RuntimeException { + public TestAbortedException() { + super(); + } + + public TestAbortedException(String message) { + super(message); + } +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java b/vm/backend/test/parparvm/com/codename1/impl/backend/test/BackendTestExtension.java similarity index 75% rename from vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java rename to vm/backend/test/parparvm/com/codename1/impl/backend/test/BackendTestExtension.java index 139b0ba97c6..696854e2085 100644 --- a/vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java +++ b/vm/backend/test/parparvm/com/codename1/impl/backend/test/BackendTestExtension.java @@ -20,16 +20,10 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package com.codename1.impl.backend.test; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/// Accepted for familiarity and otherwise ignored: [Scheduled] works without -/// it. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface EnableScheduling { +/// The compiled half of `@BackendTest`'s extension, which does nothing: a +/// translated test run has no JUnit to call it, and the generated runner does the +/// extension's work directly. It exists because the annotation names it. +public final class BackendTestExtension implements org.junit.jupiter.api.extension.Extension { } diff --git a/vm/backend/test/parparvm/com/codename1/impl/backend/test/Mocks.java b/vm/backend/test/parparvm/com/codename1/impl/backend/test/Mocks.java new file mode 100644 index 00000000000..c95ee9b5bd9 --- /dev/null +++ b/vm/backend/test/parparvm/com/codename1/impl/backend/test/Mocks.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +/// The compiled half of `@MockitoBean`, which cannot run: a mocking library builds +/// classes at run time and a translated binary has none to build. The build skips +/// classes that mock before translating them, so reaching this is a build fault. +public final class Mocks { + private Mocks() { + } + + public static Object create(Class type) { + throw new IllegalStateException("@MockitoBean runs only on the JVM; " + + type.getName() + " cannot be mocked in a compiled test"); + } + + public static void reset(Object mock) { + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/BackendTest.java b/vm/backend/test/src/com/codename1/backend/test/BackendTest.java new file mode 100644 index 00000000000..7a8ab8909e3 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/BackendTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/// Runs a test class against the application the build wired: what +/// `@SpringBootTest` does. +/// +/// The build generates, for each class carrying this, a wiring of the +/// application's beans with the class's test beans added -- its +/// [TestConfiguration]s and [MockitoBean]s -- and the code that fills the class's +/// `@Autowired`, [LocalServerPort] and [MockitoBean] fields. Nothing is looked up +/// by reflection, so the same test runs translated, as a native binary. +/// +/// One application runs at a time, as one runs per server process, and JUnit runs +/// no two classes carrying this concurrently, even with parallel execution on. Test classes +/// whose configuration is the same -- the same test beans, properties, profile +/// and web environment -- share it, as Spring caches a context; a class that +/// differs stops it and starts its own. +/// +/// It is inherited, as `@SpringBootTest` is: an abstract base class can carry it, +/// with the shared fields and tests, for concrete subclasses to run. +@Documented +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ExtendWith(com.codename1.impl.backend.test.BackendTestExtension.class) +// One application runs per process, and a class whose configuration differs stops +// it to start its own -- so under JUnit's parallel execution two such classes would +// tear each other's server down mid-test. Holding one shared resource lock makes +// JUnit run @BackendTest classes one at a time; other tests stay parallel. +@org.junit.jupiter.api.parallel.ResourceLock("com.codename1.backend.test.BackendTest") +public @interface BackendTest { + /// How the application is reached. [WebEnvironment#MOCK], the default, calls it + /// in the same process through [MockMvc]; [WebEnvironment#RANDOM_PORT] serves + /// it on a free port for [TestRestTemplate] or any HTTP client. + WebEnvironment webEnvironment() default WebEnvironment.MOCK; + + /// Settings for this test, as `key=value`, over the module's + /// `application.properties`, the profile's file and the environment: a + /// shell's `PORT` or `DATABASE_URL` does not reach a test that names its own. + String[] properties() default {}; + + /// The profile. `test` is a development profile: a database neither the test + /// nor the profile's file names is an in-memory SQLite one, whatever the + /// environment says, and the generated daos create their tables. + String profile() default "test"; + + /// [TestConfiguration] classes to add. A static nested [TestConfiguration] + /// class of the test class is added without being listed. + Class[] classes() default {}; + + /// How a [BackendTest] reaches the application. + enum WebEnvironment { + /// In process, through [MockMvc]: no request crosses a socket. + MOCK, + /// Served on a free port, which [LocalServerPort] names. + RANDOM_PORT, + /// Served on the configured `cn1.server.port`. + DEFINED_PORT, + /// The beans only; the test makes no requests. The application still + /// listens on a free loopback port, as [#MOCK] does: its server is also + /// what owns the threads its tasks run on, so it does not start without one. + NONE + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/ContentResultMatchers.java b/vm/backend/test/src/com/codename1/backend/test/ContentResultMatchers.java new file mode 100644 index 00000000000..460093b50b1 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ContentResultMatchers.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import com.codename1.backend.Json; + +/// Expectations of the response body, from [MockMvcResultMatchers#content]. +public final class ContentResultMatchers { + ContentResultMatchers() { + } + + /// The body is exactly this text. + public ResultMatcher string(String expected) { + return stringMatcher(expected); + } + + /// The body is exactly these bytes. + public ResultMatcher bytes(byte[] expected) { + return bytesMatcher(expected); + } + + /// The Content-Type is this one, parameters included when it names any: + /// `application/json` matches `application/json;charset=utf-8`, which a + /// stricter test names in full. + public ResultMatcher contentType(String expected) { + return contentTypeMatcher(expected); + } + + /// The Content-Type's type and subtype are compatible with this one; + /// `application/*` and `*/*` match anything in their range. + public ResultMatcher contentTypeCompatibleWith(String expected) { + return contentTypeCompatibleWithMatcher(expected); + } + + /// The body is JSON holding everything `expected` does; objects may carry + /// more fields. [#json(String, boolean)] with `true` asks for exact equality. + public ResultMatcher json(String expected) { + return json(expected, false); + } + + public ResultMatcher json(String expected, boolean strict) { + return jsonMatcher(expected, strict); + } + + private static String type(String contentType) { + int semi = contentType.indexOf(';'); + return (semi < 0 ? contentType : contentType.substring(0, semi)).trim(); + } + + private static ResultMatcher stringMatcher(final String expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.equal("Response content", expected, + result.getResponse().getContentAsString()); + } + }; + } + + private static ResultMatcher bytesMatcher(final byte[] expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.check(java.util.Arrays.equals(expected, + result.getResponse().getContentAsByteArray()), + "Response content does not match the expected bytes"); + } + }; + } + + private static ResultMatcher contentTypeMatcher(final String expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + String actual = result.getResponse().getContentType(); + Matchers.check(actual != null && (actual.equalsIgnoreCase(expected) + || (expected.indexOf(';') < 0 && type(actual).equalsIgnoreCase(expected))), + "Content type expected:<" + expected + "> but was:<" + actual + ">"); + } + }; + } + + private static ResultMatcher contentTypeCompatibleWithMatcher(final String expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + String actual = result.getResponse().getContentType(); + String e = type(expected); + String a = actual == null ? "" : type(actual); + boolean ok = "*/*".equals(e) || e.equalsIgnoreCase(a) + || (e.endsWith("/*") && a.regionMatches(true, 0, e, 0, e.length() - 1)); + Matchers.check(ok, "Content type [" + actual + "] is not compatible with [" + + expected + "]"); + } + }; + } + + private static ResultMatcher jsonMatcher(final String expected, final boolean strict) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object want = Json.parse(expected); + Object got = Json.parse(result.getResponse().getContentAsString()); + Matchers.check(Matchers.jsonMatches(want, got, strict), "JSON " + + (strict ? "" : "leniently ") + "expected:<" + expected + "> but was:<" + + result.getResponse().getContentAsString() + ">"); + } + }; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/CookieResultMatchers.java b/vm/backend/test/src/com/codename1/backend/test/CookieResultMatchers.java new file mode 100644 index 00000000000..da9e6b80541 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/CookieResultMatchers.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// Expectations of the cookies a response sets, from [MockMvcResultMatchers#cookie]. +public final class CookieResultMatchers { + CookieResultMatchers() { + } + + public ResultMatcher value(String name, String expected) { + return valueMatcher(name, expected); + } + + public ResultMatcher exists(String name) { + return existsMatcher(name); + } + + public ResultMatcher doesNotExist(String name) { + return doesNotExistMatcher(name); + } + + private static ResultMatcher valueMatcher(final String name, final String expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.equal("Response cookie '" + name + "'", expected, + result.getResponse().getCookie(name)); + } + }; + } + + private static ResultMatcher existsMatcher(final String name) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.check(result.getResponse().getCookie(name) != null, + "No cookie with name '" + name + "'"); + } + }; + } + + private static ResultMatcher doesNotExistMatcher(final String name) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.check(result.getResponse().getCookie(name) == null, + "Unexpected cookie with name '" + name + "'"); + } + }; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/HeaderResultMatchers.java b/vm/backend/test/src/com/codename1/backend/test/HeaderResultMatchers.java new file mode 100644 index 00000000000..1f8d28a3308 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/HeaderResultMatchers.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// Expectations of the response headers, from [MockMvcResultMatchers#header]. +public final class HeaderResultMatchers { + HeaderResultMatchers() { + } + + /// The header's first value is exactly this. + public ResultMatcher string(String name, String expected) { + return stringMatcher(name, expected); + } + + /// The header's first value is this number. + public ResultMatcher longValue(String name, long expected) { + return longValueMatcher(name, expected); + } + + public ResultMatcher exists(String name) { + return existsMatcher(name); + } + + public ResultMatcher doesNotExist(String name) { + return doesNotExistMatcher(name); + } + + private static ResultMatcher stringMatcher(final String name, final String expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.equal("Response header '" + name + "'", expected, + result.getResponse().getHeader(name)); + } + }; + } + + private static ResultMatcher longValueMatcher(final String name, final long expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + String actual = result.getResponse().getHeader(name); + Matchers.check(actual != null, "Response header '" + name + "' does not exist"); + Matchers.equal("Response header '" + name + "'", Long.valueOf(expected), + Long.valueOf(Long.parseLong(actual.trim()))); + } + }; + } + + private static ResultMatcher existsMatcher(final String name) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.check(result.getResponse().getHeader(name) != null, + "Response header '" + name + "' does not exist"); + } + }; + } + + private static ResultMatcher doesNotExistMatcher(final String name) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.check(result.getResponse().getHeader(name) == null, + "Response header '" + name + "' exists with value=" + + result.getResponse().getHeader(name)); + } + }; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/HttpEntity.java b/vm/backend/test/src/com/codename1/backend/test/HttpEntity.java new file mode 100644 index 00000000000..9ba5eff4427 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/HttpEntity.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// A body and its headers, for [TestRestTemplate#exchange]. +public class HttpEntity { + private final T body; + private final HttpHeaders headers; + + public HttpEntity(T body) { + this(body, null); + } + + public HttpEntity(HttpHeaders headers) { + this(null, headers); + } + + public HttpEntity(T body, HttpHeaders headers) { + this.body = body; + this.headers = headers == null ? new HttpHeaders() : headers; + } + + public T getBody() { + return body; + } + + public HttpHeaders getHeaders() { + return headers; + } + + public boolean hasBody() { + return body != null; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/HttpHeaders.java b/vm/backend/test/src/com/codename1/backend/test/HttpHeaders.java new file mode 100644 index 00000000000..e368d122ab4 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/HttpHeaders.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Request or response headers: names in any case, each with its values in order. +public final class HttpHeaders { + private final Map values = new LinkedHashMap(); + private final Map names = new LinkedHashMap(); + + /// Replaces a header's values with this one. + public void set(String name, String value) { + String key = MockRequestBuilder.lower(name); + List list = new ArrayList(); + list.add(value); + values.put(key, list); + names.put(key, name); + } + + /// Adds a value to a header. + public void add(String name, String value) { + String key = MockRequestBuilder.lower(name); + List list = (List) values.get(key); + if (list == null) { + list = new ArrayList(); + values.put(key, list); + names.put(key, name); + } + list.add(value); + } + + /// The first value, or null. + public String getFirst(String name) { + List list = (List) values.get(MockRequestBuilder.lower(name)); + return list == null || list.isEmpty() ? null : (String) list.get(0); + } + + /// Every value, or null when the header is absent. + public List get(String name) { + return (List) values.get(MockRequestBuilder.lower(name)); + } + + public boolean containsKey(String name) { + return values.containsKey(MockRequestBuilder.lower(name)); + } + + public void setContentType(String type) { + set("Content-Type", type); + } + + public String getContentType() { + return getFirst("Content-Type"); + } + + public void setBearerAuth(String token) { + set("Authorization", "Bearer " + token); + } + + public void setBasicAuth(String username, String password) { + set("Authorization", "Basic " + com.codename1.backend.Base64.encode( + MockRequestBuilder.utf8(username + ":" + password))); + } + + /// The names as they were first given, in order. + public List names() { + return new ArrayList(names.values()); + } + + /// "Name: value" lines, one per value, for the client. + List lines() { + List out = new ArrayList(); + java.util.Iterator it = values.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = (Map.Entry) it.next(); + List list = (List) e.getValue(); + for (Object entry : list) { + out.add(names.get(e.getKey()) + ": " + entry); + } + } + return out; + } + + @Override + public String toString() { + return values.toString(); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/HttpMethod.java b/vm/backend/test/src/com/codename1/backend/test/HttpMethod.java new file mode 100644 index 00000000000..51116c72eda --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/HttpMethod.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// The methods [TestRestTemplate#exchange] sends. +public enum HttpMethod { + GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS +} diff --git a/vm/backend/test/src/com/codename1/backend/test/HttpStatus.java b/vm/backend/test/src/com/codename1/backend/test/HttpStatus.java new file mode 100644 index 00000000000..5b7d412980f --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/HttpStatus.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// The common response statuses, so `assertEquals(HttpStatus.OK, +/// response.getStatusCode())` reads as it does in Spring. +public enum HttpStatus implements HttpStatusCode { + CONTINUE(100), + SWITCHING_PROTOCOLS(101), + OK(200), + CREATED(201), + ACCEPTED(202), + NO_CONTENT(204), + PARTIAL_CONTENT(206), + MOVED_PERMANENTLY(301), + FOUND(302), + SEE_OTHER(303), + NOT_MODIFIED(304), + TEMPORARY_REDIRECT(307), + PERMANENT_REDIRECT(308), + BAD_REQUEST(400), + UNAUTHORIZED(401), + FORBIDDEN(403), + NOT_FOUND(404), + METHOD_NOT_ALLOWED(405), + NOT_ACCEPTABLE(406), + REQUEST_TIMEOUT(408), + CONFLICT(409), + GONE(410), + LENGTH_REQUIRED(411), + PRECONDITION_FAILED(412), + PAYLOAD_TOO_LARGE(413), + UNSUPPORTED_MEDIA_TYPE(415), + REQUESTED_RANGE_NOT_SATISFIABLE(416), + EXPECTATION_FAILED(417), + UNPROCESSABLE_ENTITY(422), + TOO_MANY_REQUESTS(429), + REQUEST_HEADER_FIELDS_TOO_LARGE(431), + INTERNAL_SERVER_ERROR(500), + NOT_IMPLEMENTED(501), + BAD_GATEWAY(502), + SERVICE_UNAVAILABLE(503), + GATEWAY_TIMEOUT(504), + HTTP_VERSION_NOT_SUPPORTED(505); + + private final int code; + + HttpStatus(int code) { + this.code = code; + } + + @Override + public int value() { + return code; + } + + @Override + public boolean is1xxInformational() { + return code / 100 == 1; + } + + @Override + public boolean is2xxSuccessful() { + return code / 100 == 2; + } + + @Override + public boolean is3xxRedirection() { + return code / 100 == 3; + } + + @Override + public boolean is4xxClientError() { + return code / 100 == 4; + } + + @Override + public boolean is5xxServerError() { + return code / 100 == 5; + } + + @Override + public boolean isError() { + // 4xx or 5xx only, as Spring's HttpStatusCode: a 600 is not an error. + return is4xxClientError() || is5xxServerError(); + } + + /// The constant for `code`, or null when there is none. + public static HttpStatus resolve(int code) { + HttpStatus[] all = values(); + for (HttpStatus value : all) { + if (value.code == code) { + return value; + } + } + return null; + } + + /// The constant for `code`; IllegalArgumentException when there is none. + public static HttpStatus valueOf(int code) { + HttpStatus found = resolve(code); + if (found == null) { + throw new IllegalArgumentException("No HttpStatus constant for " + code); + } + return found; + } + + /// `code` as a status: the constant when there is one, a plain code otherwise. + static HttpStatusCode of(final int code) { + HttpStatus known = resolve(code); + if (known != null) { + return known; + } + return new HttpStatusCode() { + @Override + public int value() { + return code; + } + + @Override + public boolean is1xxInformational() { + return code / 100 == 1; + } + + @Override + public boolean is2xxSuccessful() { + return code / 100 == 2; + } + + @Override + public boolean is3xxRedirection() { + return code / 100 == 3; + } + + @Override + public boolean is4xxClientError() { + return code / 100 == 4; + } + + @Override + public boolean is5xxServerError() { + return code / 100 == 5; + } + + @Override + public boolean isError() { + return is4xxClientError() || is5xxServerError(); + } + + @Override + public boolean equals(Object other) { + return other instanceof HttpStatusCode && ((HttpStatusCode) other).value() == code; + } + + @Override + public int hashCode() { + return code; + } + + @Override + public String toString() { + return String.valueOf(code); + } + }; + } +} diff --git a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java b/vm/backend/test/src/com/codename1/backend/test/HttpStatusCode.java similarity index 74% rename from docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java rename to vm/backend/test/src/com/codename1/backend/test/HttpStatusCode.java index e786525cae0..27858160a7e 100644 --- a/docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java +++ b/vm/backend/test/src/com/codename1/backend/test/HttpStatusCode.java @@ -20,15 +20,23 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codenameone.developerguide.backend.beans; +package com.codename1.backend.test; -import com.codename1.backend.annotations.Configuration; -import com.codename1.backend.annotations.EnableManagement; +/// A response status: an [HttpStatus] for the common ones, or any other code. +public interface HttpStatusCode { + /// The numeric code. + int value(); -/** The Backend chapters' management example, compiled so it cannot drift. */ -// tag::backend-enable-management[] -@Configuration -@EnableManagement(path = "/ops") -public class OpsSettings { + boolean is1xxInformational(); + + boolean is2xxSuccessful(); + + boolean is3xxRedirection(); + + boolean is4xxClientError(); + + boolean is5xxServerError(); + + /// A 4xx or a 5xx. + boolean isError(); } -// end::backend-enable-management[] diff --git a/vm/backend/test/src/com/codename1/backend/test/JsonPath.java b/vm/backend/test/src/com/codename1/backend/test/JsonPath.java new file mode 100644 index 00000000000..5b2f9abb516 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/JsonPath.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// The JSONPath subset the matchers read: `$` then any of `.name`, +/// `['name']`, `[3]`, `[-1]`, `[*]`, `.*`, and a final `.length()`. A wildcard +/// turns the rest of the path into a list of every match. +final class JsonPath { + /// What a path that leads nowhere reads as; distinct from a JSON null. + static final Object MISSING = new Object() { + @Override + public String toString() { + return ""; + } + }; + + /// True for [#MISSING], which is one object compared by identity. + static boolean isMissing(Object value) { + return value == MISSING; //NOPMD CompareObjectsWithEquals - a sentinel, by identity + } + + private static final int NAME = 0; + private static final int INDEX = 1; + private static final int WILDCARD = 2; + private static final int LENGTH = 3; + + private final List steps; + + private JsonPath(List steps) { + this.steps = steps; + } + + static JsonPath compile(String expression) { + String e = expression.trim(); + if (!e.startsWith("$")) { + throw new IllegalArgumentException("A JSON path starts with $: " + expression); + } + List steps = new ArrayList(); + int pos = 1; + while (pos < e.length()) { + char c = e.charAt(pos); + if (c == '.') { + pos++; + if (pos < e.length() && e.charAt(pos) == '*') { + steps.add(new Object[] {Integer.valueOf(WILDCARD), null}); + pos++; + continue; + } + int end = pos; + while (end < e.length() && e.charAt(end) != '.' && e.charAt(end) != '[') { + end++; + } + String name = e.substring(pos, end); + if ("length()".equals(name) && end == e.length()) { + steps.add(new Object[] {Integer.valueOf(LENGTH), null}); + } else if (name.length() == 0) { + throw new IllegalArgumentException("An empty name in JSON path " + expression); + } else { + steps.add(new Object[] {Integer.valueOf(NAME), name}); + } + pos = end; + } else if (c == '[') { + int close = e.indexOf(']', pos); + if (close < 0) { + throw new IllegalArgumentException("An unclosed [ in JSON path " + expression); + } + String inner = e.substring(pos + 1, close).trim(); + if ("*".equals(inner)) { + steps.add(new Object[] {Integer.valueOf(WILDCARD), null}); + } else if (inner.length() >= 2 && (inner.charAt(0) == '\'' || inner.charAt(0) == '"')) { + steps.add(new Object[] {Integer.valueOf(NAME), inner.substring(1, inner.length() - 1)}); + } else { + try { + steps.add(new Object[] {Integer.valueOf(INDEX), Integer.valueOf(Integer.parseInt(inner))}); + } catch (NumberFormatException err) { + throw new IllegalArgumentException("Not an index or a quoted name: [" + inner + + "] in JSON path " + expression, err); + } + } + pos = close + 1; + } else { + throw new IllegalArgumentException("Unexpected '" + c + "' in JSON path " + expression); + } + } + return new JsonPath(steps); + } + + Object read(Object root) { + return walk(root, 0); + } + + private Object walk(Object current, int step) { + if (step == steps.size()) { + return current; + } + if (current == MISSING) { + return MISSING; + } + Object[] s = (Object[]) steps.get(step); + int kind = ((Integer) s[0]).intValue(); + if (kind == NAME) { + if (!(current instanceof Map) || !((Map) current).containsKey(s[1])) { + return MISSING; + } + return walk(((Map) current).get(s[1]), step + 1); + } + if (kind == INDEX) { + if (!(current instanceof List)) { + return MISSING; + } + List list = (List) current; + int index = ((Integer) s[1]).intValue(); + if (index < 0) { + index += list.size(); + } + if (index < 0 || index >= list.size()) { + return MISSING; + } + return walk(list.get(index), step + 1); + } + if (kind == LENGTH) { + if (current instanceof List) { + return Integer.valueOf(((List) current).size()); + } + if (current instanceof Map) { + return Integer.valueOf(((Map) current).size()); + } + if (current instanceof String) { + return Integer.valueOf(((String) current).length()); + } + return MISSING; + } + List out = new ArrayList(); + Iterator it; + if (current instanceof List) { + it = ((List) current).iterator(); + } else if (current instanceof Map) { + it = ((Map) current).values().iterator(); + } else { + return MISSING; + } + while (it.hasNext()) { + Object value = walk(it.next(), step + 1); + if (value != MISSING) { + out.add(value); + } + } + return out; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/JsonPathResultMatchers.java b/vm/backend/test/src/com/codename1/backend/test/JsonPathResultMatchers.java new file mode 100644 index 00000000000..3d1dc7ec1cb --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/JsonPathResultMatchers.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import com.codename1.backend.Json; +import java.util.List; +import java.util.Map; + +/// Expectations of one JSONPath into the JSON body, from +/// [MockMvcResultMatchers#jsonPath]. +public final class JsonPathResultMatchers { + private final String expression; + + JsonPathResultMatchers(String expression) { + this.expression = expression; + // Parsed now, so a malformed path fails where it is written. + JsonPath.compile(expression); + } + + /// The value there equals `expected`. Numbers compare by value, so `5` + /// matches the parser's `5L`; a list compares element by element. + public ResultMatcher value(final Object expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(!JsonPath.isMissing(actual), "No value at JSON path \"" + + expression + "\""); + Matchers.equal("JSON path \"" + expression + "\"", expected, actual); + } + }; + } + + /// A non-null value is there. A JSON `null` does not count, as in Spring: + /// `{"value":null}` fails `exists()` and passes [#doesNotExist]. + public ResultMatcher exists() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(!JsonPath.isMissing(actual) && actual != null, + "No value at JSON path \"" + expression + "\""); + } + }; + } + + /// No non-null value is there: the path is absent or holds JSON `null`. + public ResultMatcher doesNotExist() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(JsonPath.isMissing(actual) || actual == null, + "Expected no value at JSON path \"" + expression + "\" but found: " + actual); + } + }; + } + + /// An empty value is there: an empty string, list or map, or JSON `null`. An + /// absent path fails, as [#isNotEmpty] does -- it is not an empty value. + public ResultMatcher isEmpty() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(!JsonPath.isMissing(actual) && empty(actual), "Expected an empty value at JSON path \"" + + expression + "\" but found: " + actual); + } + }; + } + + public ResultMatcher isNotEmpty() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(!JsonPath.isMissing(actual) && !empty(actual), + "Expected a non-empty value at JSON path \"" + expression + "\""); + } + }; + } + + public ResultMatcher isArray() { + return kind(List.class, "an array"); + } + + public ResultMatcher isMap() { + return kind(Map.class, "a map"); + } + + public ResultMatcher isString() { + return kind(String.class, "a string"); + } + + public ResultMatcher isNumber() { + return kind(Number.class, "a number"); + } + + public ResultMatcher isBoolean() { + return kind(Boolean.class, "a boolean"); + } + + private ResultMatcher kind(final Class type, final String what) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(!JsonPath.isMissing(actual) && type.isInstance(actual), + "Expected " + what + " at JSON path \"" + expression + "\" but found: " + + actual); + } + }; + } + + private Object read(MvcResult result) throws Exception { + String body = result.getResponse().getContentAsString(); + return JsonPath.compile(expression).read(Json.parse(body)); + } + + private static boolean empty(Object value) { + if (value == null) { + return true; + } + if (value instanceof String) { + return ((String) value).length() == 0; + } + if (value instanceof List) { + return ((List) value).isEmpty(); + } + if (value instanceof Map) { + return ((Map) value).isEmpty(); + } + return false; + } +} diff --git a/vm/backend/src/com/codename1/backend/annotations/SessionScope.java b/vm/backend/test/src/com/codename1/backend/test/LocalServerPort.java similarity index 83% rename from vm/backend/src/com/codename1/backend/annotations/SessionScope.java rename to vm/backend/test/src/com/codename1/backend/test/LocalServerPort.java index e173dec4a4b..c4e1d30e0e7 100644 --- a/vm/backend/src/com/codename1/backend/annotations/SessionScope.java +++ b/vm/backend/test/src/com/codename1/backend/test/LocalServerPort.java @@ -20,15 +20,17 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package com.codename1.backend.test; +import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Shorthand for `@Scope("session")`. -@Retention(RetentionPolicy.CLASS) -@Target({ElementType.TYPE, ElementType.METHOD}) -public @interface SessionScope { +/// Injects the port the [BackendTest]'s server listens on into an `int` field. +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface LocalServerPort { } diff --git a/vm/backend/test/src/com/codename1/backend/test/Matchers.java b/vm/backend/test/src/com/codename1/backend/test/Matchers.java new file mode 100644 index 00000000000..9ece77fb69a --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/Matchers.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// What the result matchers share: comparisons that report the way JUnit does. +final class Matchers { + private Matchers() { + } + + static void equal(String what, Object expected, Object actual) { + if (!same(expected, actual)) { + throw new AssertionError(what + " expected:<" + expected + "> but was:<" + actual + + ">"); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + /// Equality that treats JSON numbers by value -- an expected Integer 5 matches + /// the Long 5 the parser produces, and 2.5f matches 2.5 -- and compares lists + /// and maps element by element under the same rule. + static boolean same(Object expected, Object actual) { + if (expected == actual) { //NOPMD CompareObjectsWithEquals - the same object, a shortcut + return true; + } + if (expected == null || actual == null) { + return false; + } + if (expected instanceof Number && actual instanceof Number) { + Number e = (Number) expected; + Number a = (Number) actual; + if (isIntegral(e) && isIntegral(a)) { + return e.longValue() == a.longValue(); + } + return e.doubleValue() == a.doubleValue(); + } + if (expected instanceof Character && actual instanceof String) { + return String.valueOf(expected).equals(actual); + } + if (expected instanceof List && actual instanceof List) { + List e = (List) expected; + List a = (List) actual; + if (e.size() != a.size()) { + return false; + } + for (int iter = 0 ; iter < e.size() ; iter++) { + if (!same(e.get(iter), a.get(iter))) { + return false; + } + } + return true; + } + if (expected instanceof Object[] && actual instanceof List) { + return same(java.util.Arrays.asList((Object[]) expected), actual); + } + if (expected instanceof Map && actual instanceof Map) { + Map e = (Map) expected; + Map a = (Map) actual; + if (e.size() != a.size()) { + return false; + } + Iterator it = e.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = (Map.Entry) it.next(); + if (!a.containsKey(entry.getKey()) || !same(entry.getValue(), a.get(entry.getKey()))) { + return false; + } + } + return true; + } + return expected.equals(actual); + } + + private static boolean isIntegral(Number n) { + return n instanceof Long || n instanceof Integer || n instanceof Short || n instanceof Byte; + } + + /// `actual` holds everything `expected` does: objects may carry extra fields, + /// arrays must match in length and order. Strict asks for exact equality. + static boolean jsonMatches(Object expected, Object actual, boolean strict) { + if (strict) { + return same(expected, actual); + } + if (expected instanceof Map && actual instanceof Map) { + Map e = (Map) expected; + Map a = (Map) actual; + Iterator it = e.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = (Map.Entry) it.next(); + if (!a.containsKey(entry.getKey()) + || !jsonMatches(entry.getValue(), a.get(entry.getKey()), false)) { + return false; + } + } + return true; + } + if (expected instanceof List && actual instanceof List) { + List e = (List) expected; + List a = (List) actual; + if (e.size() != a.size()) { + return false; + } + for (int iter = 0 ; iter < e.size() ; iter++) { + if (!jsonMatches(e.get(iter), a.get(iter), false)) { + return false; + } + } + return true; + } + return same(expected, actual); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MediaType.java b/vm/backend/test/src/com/codename1/backend/test/MediaType.java new file mode 100644 index 00000000000..ebbfa953737 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MediaType.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// Media types, for `contentType(...)` and `accept(...)`. Strings, so +/// `contentType(MediaType.APPLICATION_JSON)` reads as it does in Spring. +public final class MediaType { + public static final String APPLICATION_JSON = "application/json"; + public static final String APPLICATION_JSON_VALUE = APPLICATION_JSON; + public static final String APPLICATION_XML = "application/xml"; + public static final String APPLICATION_XML_VALUE = APPLICATION_XML; + public static final String APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded"; + public static final String APPLICATION_FORM_URLENCODED_VALUE = APPLICATION_FORM_URLENCODED; + public static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + public static final String APPLICATION_OCTET_STREAM_VALUE = APPLICATION_OCTET_STREAM; + public static final String MULTIPART_FORM_DATA = "multipart/form-data"; + public static final String MULTIPART_FORM_DATA_VALUE = MULTIPART_FORM_DATA; + public static final String TEXT_PLAIN = "text/plain"; + public static final String TEXT_PLAIN_VALUE = TEXT_PLAIN; + public static final String TEXT_HTML = "text/html"; + public static final String TEXT_HTML_VALUE = TEXT_HTML; + public static final String ALL = "*/*"; + public static final String ALL_VALUE = ALL; + + private MediaType() { + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockMultipartFile.java b/vm/backend/test/src/com/codename1/backend/test/MockMultipartFile.java new file mode 100644 index 00000000000..e7212cf8f0a --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMultipartFile.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// A file part for [MockMvcRequestBuilders#multipart]. +public final class MockMultipartFile { + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + /// A file part named `name`, with no filename or content type. + public MockMultipartFile(String name, byte[] content) { + this(name, null, null, content); + } + + /// A file part with the filename and content type a browser would send. + public MockMultipartFile(String name, String originalFilename, String contentType, + byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content == null ? new byte[0] : content.clone(); + } + + public String getName() { + return name; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public String getContentType() { + return contentType; + } + + public byte[] getBytes() { + return content.clone(); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockMultipartRequestBuilder.java b/vm/backend/test/src/com/codename1/backend/test/MockMultipartRequestBuilder.java new file mode 100644 index 00000000000..b5c7a3165f5 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMultipartRequestBuilder.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +/// A `multipart/form-data` request, from [MockMvcRequestBuilders#multipart]: +/// files and fields, encoded as a browser's form post would be. +public final class MockMultipartRequestBuilder extends MockRequestBuilder { + private static final String BOUNDARY = "cn1-mockmvc-boundary-7MA4YWxkTrZu0gW"; + private final List parts = new ArrayList(); + + MockMultipartRequestBuilder(String method, String uriTemplate, Object[] uriVariables) { + super(method, uriTemplate, uriVariables); + } + + /// Adds a file part. + public MockMultipartRequestBuilder file(MockMultipartFile file) { + parts.add(file); + return this; + } + + /// Adds a file part with no filename. + public MockMultipartRequestBuilder file(String name, byte[] content) { + return file(new MockMultipartFile(name, content)); + } + + /// Adds a query parameter, in the URL rather than the body. + @Override + public MockMultipartRequestBuilder queryParam(String name, String... values) { + super.queryParam(name, values); + return this; + } + + /// Adds form fields, as parts: what `@RequestParam` reads from a multipart body. + @Override + public MockMultipartRequestBuilder param(String name, String... values) { + for (String value : values) { + parts.add(new String[] {name, value}); + } + return this; + } + + @Override + byte[] body() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (Object entry : parts) { + Object part = entry; + write(out, "--" + BOUNDARY + "\r\n"); + if (part instanceof MockMultipartFile) { + MockMultipartFile f = (MockMultipartFile) part; + write(out, "Content-Disposition: form-data; name=\"" + f.getName() + "\"" + + (f.getOriginalFilename() == null ? "" + : "; filename=\"" + f.getOriginalFilename() + "\"") + "\r\n"); + if (f.getContentType() != null) { + write(out, "Content-Type: " + f.getContentType() + "\r\n"); + } + write(out, "\r\n"); + byte[] content = f.getBytes(); + out.write(content, 0, content.length); + } else if (part instanceof String[]) { + String[] field = (String[]) part; + write(out, "Content-Disposition: form-data; name=\"" + field[0] + "\"\r\n\r\n"); + write(out, field[1]); + } + write(out, "\r\n"); + } + write(out, "--" + BOUNDARY + "--\r\n"); + return out.toByteArray(); + } + + @Override + String bodyType() { + return "multipart/form-data; boundary=" + BOUNDARY; + } + + private static void write(ByteArrayOutputStream out, String text) { + byte[] bytes = utf8(text); + out.write(bytes, 0, bytes.length); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockMvc.java b/vm/backend/test/src/com/codename1/backend/test/MockMvc.java new file mode 100644 index 00000000000..37a5c3c0512 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMvc.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import com.codename1.backend.Backend; +import com.codename1.backend.HttpServer; +import com.codename1.impl.backend.BackendAccess; + +/// Calls a running backend's routes in the same process: no socket, no port, the +/// same answer. A request goes through what the listener would have done with it +/// -- the body decoded and refused by the same rules, then sessions, scoped beans, +/// CORS, compression, tracing and metrics -- on the calling thread. +/// +/// A [BackendTest] injects one into an `@Autowired MockMvc` field. A test that +/// assembles its own server can make one with [#MockMvc(Backend)]. +public final class MockMvc { + private final Backend backend; + + /// A client for `backend`, which must be running. + public MockMvc(Backend backend) { + if (backend == null) { + throw new IllegalArgumentException("No backend"); + } + this.backend = backend; + } + + /// Sends one request and returns what came back, to assert on. + public ResultActions perform(MockRequestBuilder request) throws Exception { + MockRequestBuilder.Built built = request.build(); + BackendAccess access = BackendAccess.get(); + // Never null: Backend.dispatch answers a path nothing routes with the same + // 404 the HTTP/1 and HTTP/2 listeners send, so status().isNotFound() asserts + // what a real request gets (GreetingApiTest.unknownRoutesAreNotFound in the + // developer guide's demos covers it). + HttpServer.Response response = access.dispatch(backend, built.method, built.target, + built.headers, built.body); + int status = access.status(response); + // No body where the HTTP writers send none -- a HEAD, a 1xx, 204, 205 or + // 304 -- so a test cannot assert on content no client ever receives. + boolean bodiless = "HEAD".equalsIgnoreCase(built.method) || (status >= 100 && status < 200) + || status == 204 || status == 205 || status == 304; + byte[] body; + if (bodiless) { + // Released unread: a static file's response holds the file open, and + // only reading it closed it, so each HEAD assertion leaked a descriptor. + access.discard(response); + body = new byte[0]; + } else { + body = access.body(response); + } + MockResponse mock = new MockResponse(status, access.contentType(response), + access.headers(response), body); + return new ResultActions(new MvcResult(built.method, built.target, mock)); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockMvcRequestBuilders.java b/vm/backend/test/src/com/codename1/backend/test/MockMvcRequestBuilders.java new file mode 100644 index 00000000000..0933db68142 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMvcRequestBuilders.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// The requests [MockMvc] sends, for static import: +/// +/// ```java +/// mvc.perform(post("/pets").contentType(MediaType.APPLICATION_JSON) +/// .content("{\"name\":\"Rex\"}")); +/// mvc.perform(get("/pets/{id}", 7)); +/// ``` +/// +/// `{name}` placeholders take the variables in order, percent-encoded. +public final class MockMvcRequestBuilders { + private MockMvcRequestBuilders() { + } + + public static MockRequestBuilder get(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("GET", uriTemplate, uriVariables); + } + + public static MockRequestBuilder post(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("POST", uriTemplate, uriVariables); + } + + public static MockRequestBuilder put(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("PUT", uriTemplate, uriVariables); + } + + public static MockRequestBuilder patch(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("PATCH", uriTemplate, uriVariables); + } + + public static MockRequestBuilder delete(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("DELETE", uriTemplate, uriVariables); + } + + public static MockRequestBuilder head(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("HEAD", uriTemplate, uriVariables); + } + + public static MockRequestBuilder options(String uriTemplate, Object... uriVariables) { + return new MockRequestBuilder("OPTIONS", uriTemplate, uriVariables); + } + + /// Any method, by name. + public static MockRequestBuilder request(String method, String uriTemplate, + Object... uriVariables) { + return new MockRequestBuilder(method, uriTemplate, uriVariables); + } + + /// A `multipart/form-data` POST, built with `file(...)` and `param(...)`. + public static MockMultipartRequestBuilder multipart(String uriTemplate, Object... uriVariables) { + return new MockMultipartRequestBuilder("POST", uriTemplate, uriVariables); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockMvcResultHandlers.java b/vm/backend/test/src/com/codename1/backend/test/MockMvcResultHandlers.java new file mode 100644 index 00000000000..a28fb16cc6a --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMvcResultHandlers.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.util.List; + +/// Handlers for `andDo(...)`, for static import. +public final class MockMvcResultHandlers { + private MockMvcResultHandlers() { + } + + /// Prints the request line, the status, the headers and the body. + public static ResultHandler print() { + return new ResultHandler() { + @Override + public void handle(MvcResult result) { + MockResponse r = result.getResponse(); + StringBuilder sb = new StringBuilder(); + sb.append(result.getRequestMethod()).append(' ').append(result.getRequestTarget()) + .append("\n Status = ").append(r.getStatus()); + List names = r.getHeaderNames(); + for (Object entry : names) { + String name = (String) entry; + sb.append("\n ").append(name).append(" = ").append(r.getHeaders(name)); + } + sb.append("\n Body = ").append(r.getContentAsString()); + System.out.println(sb.toString()); + } + }; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockMvcResultMatchers.java b/vm/backend/test/src/com/codename1/backend/test/MockMvcResultMatchers.java new file mode 100644 index 00000000000..ad9c0d97d5d --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMvcResultMatchers.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// The expectations `andExpect(...)` takes, for static import: +/// +/// ```java +/// mvc.perform(get("/pets/7")) +/// .andExpect(status().isOk()) +/// .andExpect(content().contentType(MediaType.APPLICATION_JSON)) +/// .andExpect(jsonPath("$.name").value("Rex")); +/// ``` +public final class MockMvcResultMatchers { + private MockMvcResultMatchers() { + } + + public static StatusResultMatchers status() { + return new StatusResultMatchers(); + } + + public static ContentResultMatchers content() { + return new ContentResultMatchers(); + } + + public static HeaderResultMatchers header() { + return new HeaderResultMatchers(); + } + + public static CookieResultMatchers cookie() { + return new CookieResultMatchers(); + } + + /// A JSONPath into the JSON body: `$.name`, `$.items[0].id`, `$.items[*].id`, + /// `$['odd key']`, `$.items.length()`. Each `%s` in the expression takes the + /// next of `args`. + public static JsonPathResultMatchers jsonPath(String expression, Object... args) { + return new JsonPathResultMatchers(substitute(expression, args)); + } + + /// The response redirects to exactly this location. + public static ResultMatcher redirectedUrl(final String expected) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.equal("Redirected URL", expected, result.getResponse().getRedirectedUrl()); + } + }; + } + + static String substitute(String expression, Object[] args) { + if (args == null || args.length == 0) { + return expression; + } + StringBuilder sb = new StringBuilder(); + int next = 0; + int pos = 0; + while (pos < expression.length()) { + int at = expression.indexOf("%s", pos); + if (at < 0 || next >= args.length) { + break; + } + sb.append(expression, pos, at).append(args[next++]); + pos = at + 2; + } + sb.append(expression.substring(pos)); + return sb.toString(); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockRequestBuilder.java b/vm/backend/test/src/com/codename1/backend/test/MockRequestBuilder.java new file mode 100644 index 00000000000..673f578e4e1 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockRequestBuilder.java @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// One request for [MockMvc], built by [MockMvcRequestBuilders]. +public class MockRequestBuilder { + final String method; + private final String path; + private final List headerNames = new ArrayList(); + private final Map headerValues = new LinkedHashMap(); + private final List params = new ArrayList(); + private final List cookies = new ArrayList(); + byte[] content; + String contentType; + + MockRequestBuilder(String method, String uriTemplate, Object[] uriVariables) { + this.method = method; + this.path = expand(uriTemplate, uriVariables); + } + + /// Adds a header; several values are sent as one comma-separated field. + public MockRequestBuilder header(String name, Object... values) { + String key = lower(name); + List existing = (List) headerValues.get(key); + if (existing == null) { + existing = new ArrayList(); + headerValues.put(key, existing); + headerNames.add(name); + } + for (Object value : values) { + existing.add(String.valueOf(value)); + } + return this; + } + + /// Adds a request parameter: a query parameter, which `@RequestParam` reads. + public MockRequestBuilder param(String name, String... values) { + return addQuery(name, values); + } + + /// Adds a query parameter: the same as [#param], except on a multipart + /// builder, whose param() adds a form field instead and whose queryParam() + /// still puts the value in the URL. + public MockRequestBuilder queryParam(String name, String... values) { + return addQuery(name, values); + } + + private MockRequestBuilder addQuery(String name, String... values) { + for (String value : values) { + params.add(new String[] {name, value}); + } + return this; + } + + /// The body, as UTF-8 text. + public MockRequestBuilder content(String body) { + this.content = body == null ? null : utf8(body); + return this; + } + + /// The body, as bytes. + public MockRequestBuilder content(byte[] body) { + this.content = body == null ? null : body.clone(); + return this; + } + + /// The Content-Type header. + public MockRequestBuilder contentType(String type) { + this.contentType = type; + return this; + } + + /// The Accept header. + public MockRequestBuilder accept(String... types) { + StringBuilder sb = new StringBuilder(); + for (int iter = 0 ; iter < types.length ; iter++) { + if (iter > 0) { + sb.append(", "); + } + sb.append(types[iter]); + } + headerValues.remove("accept"); + return header("Accept", sb.toString()); + } + + /// A cookie, sent in the Cookie header. + public MockRequestBuilder cookie(String name, String value) { + cookies.add(name + "=" + value); + return this; + } + + /// Accepted for familiarity; bodies are always UTF-8 here. + public MockRequestBuilder characterEncoding(String encoding) { + return this; + } + + /// The request as MockMvc sends it. + Built build() { + Built out = new Built(); + out.method = method; + StringBuilder target = new StringBuilder(path); + for (Object entry : params) { + String[] p = (String[]) entry; + target.append(target.toString().indexOf('?') < 0 ? '?' : '&').append(encode(p[0])).append('=') + .append(encode(p[1])); + } + out.target = target.toString(); + out.headers = new LinkedHashMap(); + for (Object entry : headerNames) { + String name = (String) entry; + List values = (List) headerValues.get(lower(name)); + if (values == null) { + continue; + } + StringBuilder joined = new StringBuilder(); + for (int v = 0 ; v < values.size() ; v++) { + if (v > 0) { + joined.append(", "); + } + joined.append(values.get(v)); + } + out.headers.put(name, joined.toString()); + } + if (!cookies.isEmpty()) { + StringBuilder joined = new StringBuilder(); + for (int iter = 0 ; iter < cookies.size() ; iter++) { + if (iter > 0) { + joined.append("; "); + } + joined.append(cookies.get(iter)); + } + out.headers.put("Cookie", joined.toString()); + } + byte[] body = body(); + String type = bodyType(); + if (type != null) { + out.headers.put("Content-Type", type); + } + out.body = body; + if (body != null) { + out.headers.put("Content-Length", String.valueOf(body.length)); + } + // By name in any case: a test's .header("host", ...) is the Host the + // request carries, and the dispatch folds names, so a default added + // beside it under another spelling replaced it. + if (!containsName(out.headers, "Host")) { + out.headers.put("Host", "localhost"); + } + return out; + } + + private static boolean containsName(Map headers, String name) { + for (Object key : headers.keySet()) { + if (name.equalsIgnoreCase(String.valueOf(key))) { + return true; + } + } + return false; + } + + /// The body to send; a multipart builder assembles its own. + byte[] body() { + return content; + } + + /// The Content-Type to send; a multipart builder names its boundary. + String bodyType() { + return contentType; + } + + /// A built request: what [MockMvc] hands the server. + static final class Built { + String method; + String target; + Map headers; + byte[] body; + } + + /// `/pets/{id}` with its variables substituted in order, each percent-encoded + /// as a path segment. + static String expand(String template, Object[] variables) { + if (variables == null || variables.length == 0 || template.indexOf('{') < 0) { + return template; + } + StringBuilder sb = new StringBuilder(); + int next = 0; + int pos = 0; + while (pos < template.length()) { + int open = template.indexOf('{', pos); + if (open < 0) { + break; + } + int close = template.indexOf('}', open); + if (close < 0) { + break; + } + sb.append(template, pos, open); + if (next >= variables.length) { + throw new IllegalArgumentException("Not enough variables for " + template); + } + sb.append(encode(String.valueOf(variables[next++]))); + pos = close + 1; + } + sb.append(template.substring(pos)); + return sb.toString(); + } + + /// Percent-encodes everything but the unreserved characters of RFC 3986. + static String encode(String value) { + byte[] bytes = utf8(value); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + int c = b & 0xff; + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '-' || c == '.' || c == '_' || c == '~') { + sb.append((char) c); + } else { + sb.append('%').append(HEX.charAt(c >> 4)).append(HEX.charAt(c & 15)); + } + } + return sb.toString(); + } + + private static final String HEX = "0123456789ABCDEF"; + + static byte[] utf8(String value) { + try { + return value.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString(), err); + } + } + + static String lower(String value) { + char[] chars = value.toCharArray(); + for (int iter = 0 ; iter < chars.length ; iter++) { + if (chars[iter] >= 'A' && chars[iter] <= 'Z') { + chars[iter] = (char) (chars[iter] + 32); + } + } + return new String(chars); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/MockResponse.java b/vm/backend/test/src/com/codename1/backend/test/MockResponse.java new file mode 100644 index 00000000000..224c9f36c1f --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockResponse.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import java.util.ArrayList; +import java.util.List; + +/// What a [MockMvc] request came back with. +public final class MockResponse { + private final int status; + private final String contentType; + private final List headers; + private final byte[] body; + + MockResponse(int status, String contentType, List headers, byte[] body) { + this.status = status; + this.contentType = contentType; + // The server keeps a response's type apart from its other headers, but on + // the wire it is one header like the rest: getHeader("Content-Type"), + // header().exists and getHeaderNames all have to see it, or MockMvc + // reports a header absent that the real response carries. + List all = new ArrayList(); + boolean typed = false; + for (Object entry : headers) { + typed |= "Content-Type".equalsIgnoreCase(((String[]) entry)[0]); + } + if (contentType != null && !typed) { + all.add(new String[] {"Content-Type", contentType}); + } + all.addAll(headers); + this.headers = all; + this.body = body == null ? new byte[0] : body; + } + + public int getStatus() { + return status; + } + + /// The Content-Type, or null. + public String getContentType() { + String header = getHeader("Content-Type"); + return header != null ? header : contentType; + } + + /// The body as UTF-8 text. + public String getContentAsString() { + try { + return new String(body, "UTF-8"); + } catch (java.io.UnsupportedEncodingException err) { + throw new IllegalStateException(err.toString(), err); + } + } + + public byte[] getContentAsByteArray() { + return body.clone(); + } + + /// The first value of a header, by a name in any case; or null. + public String getHeader(String name) { + List all = getHeaders(name); + return all.isEmpty() ? null : (String) all.get(0); + } + + /// Every value of a header, by a name in any case. + public List getHeaders(String name) { + List out = new ArrayList(); + for (Object entry : headers) { + String[] h = (String[]) entry; + if (h[0].equalsIgnoreCase(name)) { + out.add(h[1]); + } + } + return out; + } + + /// The names of the headers set, in order. + public List getHeaderNames() { + List out = new ArrayList(); + for (Object entry : headers) { + String name = ((String[]) entry)[0]; + if (!out.contains(name)) { + out.add(name); + } + } + return out; + } + + /// The value of a cookie this response sets, or null. + public String getCookie(String name) { + List all = getHeaders("Set-Cookie"); + for (Object entry : all) { + String cookie = (String) entry; + int eq = cookie.indexOf('='); + if (eq > 0 && cookie.substring(0, eq).trim().equals(name)) { + int semi = cookie.indexOf(';', eq); + return cookie.substring(eq + 1, semi < 0 ? cookie.length() : semi).trim(); + } + } + return null; + } + + /// The Location of a redirect, or null. + public String getRedirectedUrl() { + return getHeader("Location"); + } +} diff --git a/vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java b/vm/backend/test/src/com/codename1/backend/test/MockitoBean.java similarity index 62% rename from vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java rename to vm/backend/test/src/com/codename1/backend/test/MockitoBean.java index f217f17d544..8070d7d39ea 100644 --- a/vm/backend/src/com/codename1/backend/annotations/StaticFilesConfig.java +++ b/vm/backend/test/src/com/codename1/backend/test/MockitoBean.java @@ -20,30 +20,32 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package com.codename1.backend.test; +import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Static file settings, compiled in. +/// Replaces every bean of the field's type with a Mockito mock, and injects it: /// -/// Each attribute is the `cn1.static.*` key named beside it, and sets that key's -/// value at the bottom of the configuration, so a properties file or the -/// environment still override it. An attribute left at its default sets nothing. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface StaticFilesConfig { - /// `cn1.static.root`: the directory to serve. - String root() default ""; - - /// `cn1.static.prefix`: the path they're served under. - String prefix() default ""; - - /// `cn1.static.index`: the file a directory answers with. - String index() default ""; - - /// `cn1.static.cacheControl`: their `Cache-Control` header. - String cacheControl() default ""; +/// ```java +/// @MockitoBean Mailer mailer; +/// +/// @Test +/// void signupSendsMail() throws Exception { +/// mvc.perform(post("/signup").content("ada@example.com")); +/// verify(mailer).send(eq("ada@example.com"), anyString()); +/// } +/// ``` +/// +/// The mock is reset after each test. It needs Mockito on the test classpath and +/// runs only on the JVM: a mocking library builds classes at run time, which a +/// translated binary cannot, so a compiled test run reports these classes as +/// skipped. A [TestConfiguration] with a `@Primary` fake works in both. +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface MockitoBean { } diff --git a/vm/backend/test/src/com/codename1/backend/test/MvcResult.java b/vm/backend/test/src/com/codename1/backend/test/MvcResult.java new file mode 100644 index 00000000000..49cec3c6039 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MvcResult.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// One [MockMvc] exchange: what was sent and what came back. +public final class MvcResult { + private final String method; + private final String target; + private final MockResponse response; + + MvcResult(String method, String target, MockResponse response) { + this.method = method; + this.target = target; + this.response = response; + } + + public MockResponse getResponse() { + return response; + } + + /// The method sent. + public String getRequestMethod() { + return method; + } + + /// The path and query sent. + public String getRequestTarget() { + return target; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/ResponseEntity.java b/vm/backend/test/src/com/codename1/backend/test/ResponseEntity.java new file mode 100644 index 00000000000..75bc7d9c764 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ResponseEntity.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// A [TestRestTemplate] response: status, headers and the body converted to the +/// type asked for. +public final class ResponseEntity extends HttpEntity { + private final HttpStatusCode status; + + ResponseEntity(T body, HttpHeaders headers, int status) { + super(body, headers); + this.status = HttpStatus.of(status); + } + + public HttpStatusCode getStatusCode() { + return status; + } + + public int getStatusCodeValue() { + return status.value(); + } + + @Override + public String toString() { + return "<" + status.value() + "," + getBody() + "," + getHeaders() + ">"; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/ResultActions.java b/vm/backend/test/src/com/codename1/backend/test/ResultActions.java new file mode 100644 index 00000000000..edac47109eb --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ResultActions.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// What to do with a [MockMvc] result: assert on it, act on it, or return it. +public final class ResultActions { + private final MvcResult result; + + ResultActions(MvcResult result) { + this.result = result; + } + + /// Asserts one expectation, throwing AssertionError when it fails. + public ResultActions andExpect(ResultMatcher matcher) throws Exception { + matcher.match(result); + return this; + } + + /// Asserts every expectation, reporting all the failures together. A matcher + /// that throws -- a body that is not JSON, a header that is not a number -- is + /// one of the failures and the rest still run; an `Error` other than an + /// assertion failure still ends the run. + public ResultActions andExpectAll(ResultMatcher... matchers) throws Exception { + StringBuilder failures = null; + for (ResultMatcher value : matchers) { + String failure; + try { + value.match(result); + continue; + } catch (AssertionError failed) { + failure = failed.getMessage(); + } catch (Exception failed) { + failure = failed.getClass().getName() + ": " + failed.getMessage(); + } + if (failures == null) { + failures = new StringBuilder("Multiple expectations failed:"); + } + failures.append("\n\t").append(failure); + } + if (failures != null) { + throw new AssertionError(failures.toString()); + } + return this; + } + + public ResultActions andDo(ResultHandler handler) throws Exception { + handler.handle(result); + return this; + } + + public MvcResult andReturn() { + return result; + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/ResultHandler.java b/vm/backend/test/src/com/codename1/backend/test/ResultHandler.java new file mode 100644 index 00000000000..941020f394a --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ResultHandler.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// Something done with a [MockMvc] result, such as [MockMvcResultHandlers#print]. +public interface ResultHandler { + void handle(MvcResult result) throws Exception; +} diff --git a/vm/backend/test/src/com/codename1/backend/test/ResultMatcher.java b/vm/backend/test/src/com/codename1/backend/test/ResultMatcher.java new file mode 100644 index 00000000000..a759f721923 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ResultMatcher.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// One expectation of a [MockMvc] result, from [MockMvcResultMatchers]. Throws an +/// AssertionError when the result does not meet it. +public interface ResultMatcher { + void match(MvcResult result) throws Exception; +} diff --git a/vm/backend/test/src/com/codename1/backend/test/StatusResultMatchers.java b/vm/backend/test/src/com/codename1/backend/test/StatusResultMatchers.java new file mode 100644 index 00000000000..edd033d26a5 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/StatusResultMatchers.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +/// Expectations of the response status, from [MockMvcResultMatchers#status]. +public final class StatusResultMatchers { + StatusResultMatchers() { + } + + /// Exactly this status. + public ResultMatcher is(int status) { + return isMatcher(status); + } + + private static ResultMatcher range(final int hundreds, final String name) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + int status = result.getResponse().getStatus(); + Matchers.check(status / 100 == hundreds, "Range for response status value " + + status + " expected:<" + name + "> but was:<" + (status / 100) + "xx>"); + } + }; + } + + public ResultMatcher is1xxInformational() { + return range(1, "1xx"); + } + + public ResultMatcher is2xxSuccessful() { + return range(2, "2xx"); + } + + public ResultMatcher is3xxRedirection() { + return range(3, "3xx"); + } + + public ResultMatcher is4xxClientError() { + return range(4, "4xx"); + } + + public ResultMatcher is5xxServerError() { + return range(5, "5xx"); + } + + public ResultMatcher isOk() { + return is(200); + } + + public ResultMatcher isCreated() { + return is(201); + } + + public ResultMatcher isAccepted() { + return is(202); + } + + public ResultMatcher isNoContent() { + return is(204); + } + + public ResultMatcher isPartialContent() { + return is(206); + } + + public ResultMatcher isMovedPermanently() { + return is(301); + } + + public ResultMatcher isFound() { + return is(302); + } + + public ResultMatcher isSeeOther() { + return is(303); + } + + public ResultMatcher isNotModified() { + return is(304); + } + + public ResultMatcher isTemporaryRedirect() { + return is(307); + } + + public ResultMatcher isPermanentRedirect() { + return is(308); + } + + public ResultMatcher isBadRequest() { + return is(400); + } + + public ResultMatcher isUnauthorized() { + return is(401); + } + + public ResultMatcher isForbidden() { + return is(403); + } + + public ResultMatcher isNotFound() { + return is(404); + } + + public ResultMatcher isMethodNotAllowed() { + return is(405); + } + + public ResultMatcher isNotAcceptable() { + return is(406); + } + + public ResultMatcher isConflict() { + return is(409); + } + + public ResultMatcher isGone() { + return is(410); + } + + public ResultMatcher isPayloadTooLarge() { + return is(413); + } + + public ResultMatcher isUnsupportedMediaType() { + return is(415); + } + + public ResultMatcher isUnprocessableEntity() { + return is(422); + } + + public ResultMatcher isTooManyRequests() { + return is(429); + } + + public ResultMatcher isInternalServerError() { + return is(500); + } + + public ResultMatcher isNotImplemented() { + return is(501); + } + + public ResultMatcher isServiceUnavailable() { + return is(503); + } + + private static ResultMatcher isMatcher(final int status) { + return new ResultMatcher() { + @Override + public void match(MvcResult result) { + Matchers.equal("Status", Integer.valueOf(status), + Integer.valueOf(result.getResponse().getStatus())); + } + }; + } +} diff --git a/vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java b/vm/backend/test/src/com/codename1/backend/test/TestConfiguration.java similarity index 59% rename from vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java rename to vm/backend/test/src/com/codename1/backend/test/TestConfiguration.java index 62637aca6fb..13b8887a462 100644 --- a/vm/backend/src/com/codename1/backend/annotations/EnableMcpServer.java +++ b/vm/backend/test/src/com/codename1/backend/test/TestConfiguration.java @@ -20,28 +20,36 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.backend.annotations; +package com.codename1.backend.test; +import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/// Builds the MCP endpoint into the server, serving the application's -/// [McpTool] methods over Streamable HTTP at `/mcp`. +/// A class whose `@Bean` methods add beans for a [BackendTest], and only for it. /// -/// A class with an [McpTool] method already does this, so the annotation is -/// needed only to move the endpoint or to name the origins it accepts. Without -/// either -- and without `cn1.mcp.enabled=true` in a properties file -- the entry -/// point of a packaged server never names the endpoint and the translator leaves -/// its code out of the binary. The endpoint serves only when it has a tool, and -/// `cn1.mcp.enabled=false` turns it off at start-up. -@Retention(RetentionPolicy.CLASS) +/// A static nested class of the test is added to that test; a top-level one is +/// added by listing it in [BackendTest#classes]. A test bean is an ordinary +/// singleton: it is injected, it receives its own injections, and it may be +/// marked `@Primary` to win over the application's bean of the same type -- +/// which is how a test swaps an implementation without a mocking library: +/// +/// ```java +/// @TestConfiguration +/// static class Fakes { +/// @Bean @Primary +/// Mailer mailer() { +/// return new RecordingMailer(); +/// } +/// } +/// ``` +/// +/// Test beans may not be request-, session-scoped or lazy, and may not carry +/// `@Transactional`, `@Async` or the other woven annotations. +@Documented +@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) -public @interface EnableMcpServer { - /// Where the endpoint is served; `cn1.mcp.path`. Empty means `/mcp`. - String path() default ""; - - /// The browser origins allowed to call it; `cn1.mcp.allowedOrigins`. - String[] allowedOrigins() default {}; +public @interface TestConfiguration { } diff --git a/vm/backend/test/src/com/codename1/backend/test/TestRestTemplate.java b/vm/backend/test/src/com/codename1/backend/test/TestRestTemplate.java new file mode 100644 index 00000000000..86ad9d8d2e9 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/TestRestTemplate.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend.test; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// An HTTP client for a [BackendTest] served on a port -- `webEnvironment = +/// RANDOM_PORT` -- through the backend's own outbound client, so a compiled test +/// sends real requests from the native binary too. +/// +/// A path is relative to the server; an absolute URL is sent as it is. A 4xx or +/// 5xx is a response to assert on, not an exception. Bodies convert to and from +/// `String`, `byte[]`, and `Map` or `List` as JSON. +/// +/// The Java SE client behind `cn1:backend` cannot send PATCH (its HTTP client has +/// a fixed set of verbs); use [MockMvc] for a PATCH route, or a compiled run. +public final class TestRestTemplate { + private final String rootUri; + private final String authorization; + + /// A client for the server at `rootUri`, such as `http://127.0.0.1:8080`. + public TestRestTemplate(String rootUri) { + this(rootUri, null); + } + + private TestRestTemplate(String rootUri, String authorization) { + String root = rootUri; + while (root.endsWith("/")) { + root = root.substring(0, root.length() - 1); + } + this.rootUri = root; + this.authorization = authorization; + } + + public String getRootUri() { + return rootUri; + } + + /// This client, sending HTTP Basic credentials with every request. + public TestRestTemplate withBasicAuth(String username, String password) { + return new TestRestTemplate(rootUri, "Basic " + com.codename1.backend.Base64.encode( + MockRequestBuilder.utf8(username + ":" + password))); + } + + public ResponseEntity getForEntity(String url, Class responseType, + Object... uriVariables) throws IOException { + return exchange(url, HttpMethod.GET, null, responseType, uriVariables); + } + + public T getForObject(String url, Class responseType, Object... uriVariables) + throws IOException { + return getForEntity(url, responseType, uriVariables).getBody(); + } + + public ResponseEntity postForEntity(String url, Object request, Class responseType, + Object... uriVariables) throws IOException { + return exchange(url, HttpMethod.POST, entity(request), responseType, uriVariables); + } + + public T postForObject(String url, Object request, Class responseType, + Object... uriVariables) throws IOException { + return postForEntity(url, request, responseType, uriVariables).getBody(); + } + + public void put(String url, Object request, Object... uriVariables) throws IOException { + exchange(url, HttpMethod.PUT, entity(request), Void.class, uriVariables); + } + + public void delete(String url, Object... uriVariables) throws IOException { + exchange(url, HttpMethod.DELETE, null, Void.class, uriVariables); + } + + /// Sends any method with any body and headers. + public ResponseEntity exchange(String url, HttpMethod method, HttpEntity requestEntity, + Class responseType, Object... uriVariables) + throws IOException { + String target = MockRequestBuilder.expand(url, uriVariables); + if (!target.startsWith("http://") && !target.startsWith("https://")) { + target = rootUri + (target.startsWith("/") ? "" : "/") + target; + } + HttpHeaders headers = requestEntity == null ? new HttpHeaders() : requestEntity.getHeaders(); + byte[] body = null; + if (requestEntity != null && requestEntity.hasBody()) { + Object value = requestEntity.getBody(); + String type = headers.getContentType(); + if (value instanceof byte[]) { + body = (byte[]) value; + type = type == null ? MediaType.APPLICATION_OCTET_STREAM : type; + } else if (value instanceof String) { + body = MockRequestBuilder.utf8((String) value); + type = type == null ? "text/plain; charset=utf-8" : type; + } else if (value instanceof Map || value instanceof List) { + body = MockRequestBuilder.utf8(Json.write(value)); + type = type == null ? MediaType.APPLICATION_JSON : type; + } else { + throw new IllegalArgumentException("A request body is a String, a byte[], a Map or " + + "a List; " + value.getClass().getName() + " has no conversion here"); + } + if (headers.getContentType() == null) { + headers = copy(headers); + headers.setContentType(type); + } + } + List lines = headers.lines(); + if (authorization != null && !headers.containsKey("Authorization")) { + lines = new ArrayList(lines); + lines.add("Authorization: " + authorization); + } + Web.Result result = Web.request(method.name(), target, lines, body); + if (result.getStatus() < 0) { + throw new IOException("The request to " + target + " failed: " + result.getError()); + } + HttpHeaders responseHeaders = new HttpHeaders(); + Iterator it = result.getHeaders().keySet().iterator(); + while (it.hasNext()) { + String name = String.valueOf(it.next()); + List values = result.getHeaderValues(name); + for (int iter = 0 ; values != null && iter < values.size() ; iter++) { + responseHeaders.add(name, String.valueOf(values.get(iter))); + } + } + return new ResponseEntity(convert(result.getBody(), responseType), responseHeaders, + result.getStatus()); + } + + private static HttpEntity entity(Object request) { + if (request == null) { + return null; + } + if (request instanceof HttpEntity) { + return (HttpEntity) request; + } + return new HttpEntity(request); + } + + private static HttpHeaders copy(HttpHeaders headers) { + HttpHeaders out = new HttpHeaders(); + List names = headers.names(); + for (Object entry : names) { + String name = (String) entry; + List values = headers.get(name); + for (int v = 0 ; values != null && v < values.size() ; v++) { + out.add(name, (String) values.get(v)); + } + } + return out; + } + + @SuppressWarnings("unchecked") + private static T convert(byte[] body, Class type) throws IOException { + if (type == null || type == Void.class) { + return null; + } + if (type == byte[].class) { + return (T) (body == null ? new byte[0] : body); + } + String text = body == null ? "" : new String(body, "UTF-8"); + if (type == String.class) { + return (T) text; + } + if (type == Map.class || type == List.class || type == Object.class) { + Object parsed = text.trim().length() == 0 ? null : Json.parse(text); + if (parsed != null && !type.isInstance(parsed)) { + throw new IOException("The response is JSON but not a " + type.getName() + ": " + + text); + } + return (T) parsed; + } + throw new IllegalArgumentException("A response converts to String, byte[], Map, List or " + + "Void; " + type.getName() + " has no conversion here"); + } +} diff --git a/vm/backend/test/src/com/codename1/backend/test/package-info.java b/vm/backend/test/src/com/codename1/backend/test/package-info.java new file mode 100644 index 00000000000..a8a6eeee381 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/package-info.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Testing a backend the way Spring Boot tests are written, on the JVM and as a +/// compiled native test. +/// +/// A class annotated [BackendTest] gets the application the build wired, started +/// in the test with the `test` profile -- an in-memory database, tables created -- +/// and its `@Autowired` fields filled from it: +/// +/// ```java +/// @BackendTest +/// class GreeterApiTest { +/// @Autowired MockMvc mvc; +/// @Autowired Greeter greeter; +/// +/// @Test +/// void greets() throws Exception { +/// mvc.perform(get("/greet/{name}", "Ada")) +/// .andExpect(status().isOk()) +/// .andExpect(content().string("Hello, Ada")); +/// } +/// } +/// ``` +/// +/// The same source runs two ways. Under Maven Surefire it is an ordinary JUnit 5 +/// test. With `-Dcn1.backend.compiledTests=true` the build translates it with the +/// application and runs it as a native binary, the way the server ships -- there +/// is no JUnit and no reflection in that binary, so the build finds the tests in +/// the bytecode and writes the calls itself. +/// +/// The names follow Spring's so a test ports by changing its imports: +/// [MockMvc], `perform`, [MockMvcRequestBuilders], [MockMvcResultMatchers], +/// [TestConfiguration], [MockitoBean], [LocalServerPort] and [TestRestTemplate]. +/// What is left out is what Spring keeps for compatibility with servlets. +package com.codename1.backend.test; diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestBeans.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestBeans.java new file mode 100644 index 00000000000..ed008996c8c --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestBeans.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +/// What a test wiring adds to an application's: its beans by name, so a test's +/// `@Autowired` fields can be filled. Only generated test wirings implement it -- +/// the production wiring has no lookup of any kind. +public interface TestBeans { + /// The bean of this name -- the instance, or for a scoped or lazy bean its + /// stand-in -- or null when its condition left it out. + Object bean(String name); +} diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java new file mode 100644 index 00000000000..3faf0bce4a7 --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +import com.codename1.impl.backend.BackendApplication; + +/// One test class's configuration, generated by the build as +/// `Cn1TestContext`: what to start, and how to fill the test's fields +/// from it. +public interface TestContext { + /// Equal for two classes whose applications would be the same, so one running + /// application serves both. + String key(); + + /// The module's `application.properties`, compiled in, as key and value pairs. + String[] compiledSettings(); + + /// The profile's `application-.properties`, compiled in, as key and + /// value pairs. Above [#compiledSettings] and below the environment, where a + /// server reads that file. + String[] profileSettings(); + + /// The test's own properties, as key and value pairs. These win over every + /// other layer, the environment included. + String[] properties(); + + String profile(); + + /// The ordinal of `BackendTest.WebEnvironment`. + int webEnvironment(); + + /// Whether the beans need a database, as the generated main asks. + boolean requiresDataSource(); + + /// Runs before the application starts: registers the generated daos. + void prepare(); + + /// A new wiring of this test's beans. + BackendApplication createApplication(); + + /// The names of the mock beans, reset after each test. + String[] mockBeans(); + + /// Fills the test instance's `@Autowired`, `@LocalServerPort` and + /// `@MockitoBean` fields. + void inject(Object test, TestEnvironment environment) throws Exception; +} diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestContexts.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestContexts.java new file mode 100644 index 00000000000..e546bda291b --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestContexts.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +import com.codename1.backend.Backend; +import com.codename1.backend.Config; +import com.codename1.impl.backend.BackendAccess; +import java.util.Properties; + +/// The one running test application, started for a context and kept for the +/// next class whose context has the same key -- the context cache Spring keeps, +/// with the size this runtime allows: one backend runs per process. +public final class TestContexts { + /// BackendTest.WebEnvironment.DEFINED_PORT's ordinal. + private static final int DEFINED_PORT = 2; + + private static String currentKey; + private static TestEnvironment current; + + private TestContexts() { + } + + /// The running application for `context`, starting it -- and stopping one + /// started for another key -- when it is not already running. + public static synchronized TestEnvironment acquire(TestContext context) throws Exception { + if (current != null && context.key().equals(currentKey)) { + return current; + } + shutdown(); + Properties settings = new Properties(); + String[] pairs = context.properties(); + for (int iter = 0 ; iter + 1 < pairs.length ; iter += 2) { + settings.setProperty(pairs[iter], pairs[iter + 1]); + } + String[] profileSettings = context.profileSettings(); + if (Config.of(null, context.profile()).isDevelopmentProfile() + && settings.getProperty(Config.DATASOURCE_URL) == null + && !names(profileSettings, Config.DATASOURCE_URL)) { + // A committed application.properties names the production database -- + // cn1.datasource.url=${DATABASE_URL} -- and is compiled in under this. + // A test on a development profile gets an in-memory one instead, unless + // the test or the profile's file names one, as the dev profile does. + // In the top layer, so a DATABASE_URL exported in the developer's shell + // does not point the tests at that database. + settings.setProperty(Config.DATASOURCE_URL, ":memory:"); + } + boolean defined = context.webEnvironment() == DEFINED_PORT; + // MOCK and NONE listen too, on a free loopback port. Spring starts no web + // server for NONE, but here the HttpServer is also what hosts the + // application's executors and virtual threads (Tasks.Registry.server), so + // an application with no server would run its tasks differently from the + // one it tests. A loopback port nobody connects to is the cheaper price. + if (!defined && settings.getProperty(Config.SERVER_PORT) == null) { + // A free port the system picks, read back from the listener. + settings.setProperty(Config.SERVER_PORT, "0"); + } + BackendAccess access = BackendAccess.get(); + Config config = access.testConfig(settings, context.profile()); + Backend.Builder builder = Backend.builder(config).quiet(); + if (!defined) { + builder.host("127.0.0.1"); + } + if (context.requiresDataSource()) { + builder.requiresDataSource(); + } + access.compiledSettings(builder, concat(context.compiledSettings(), profileSettings)); + context.prepare(); + access.application(builder, context.createApplication()); + Backend backend = builder.start(); + current = new TestEnvironment(context, backend); + currentKey = context.key(); + return current; + } + + /// Whether `pairs`, keys and values alternating, sets `key`. + private static boolean names(String[] pairs, String key) { + for (int iter = 0 ; iter + 1 < pairs.length ; iter += 2) { + if (key.equals(pairs[iter])) { + return true; + } + } + return false; + } + + /// The base file's pairs, then the profile file's: later pairs win in the + /// compiled layer, as the profile file wins over the base file. + private static String[] concat(String[] base, String[] profile) { + String[] out = new String[base.length + profile.length]; + System.arraycopy(base, 0, out, 0, base.length); + System.arraycopy(profile, 0, out, base.length, profile.length); + return out; + } + + /// Stops the running application, if any. The JUnit extension calls this from a + /// shutdown hook; the compiled runner calls it before it exits. + public static synchronized void shutdown() { + if (current != null) { + TestEnvironment stopping = current; + current = null; + currentKey = null; + stopping.backend().stop(); + } + } +} diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.java new file mode 100644 index 00000000000..6247d841a6f --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +import com.codename1.backend.Backend; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.TestRestTemplate; +import com.codename1.impl.backend.BackendAccess; + +/// A running test application: the server, its beans, and the clients a test +/// injects. +public final class TestEnvironment { + private final TestContext context; + private final Backend backend; + private MockMvc mockMvc; + private TestRestTemplate restTemplate; + + TestEnvironment(TestContext context, Backend backend) { + this.context = context; + this.backend = backend; + } + + public TestContext context() { + return context; + } + + public Backend backend() { + return backend; + } + + /// The port the server listens on. + public int port() { + return backend.getServer().getPort(); + } + + /// The bean of this name from the running wiring, or null. + public Object bean(String name) { + Object app = BackendAccess.get().applicationOf(backend); + if (app instanceof TestBeans) { + return ((TestBeans) app).bean(name); + } + throw new IllegalStateException("The running application is not a test wiring"); + } + + public MockMvc mockMvc() { + if (mockMvc == null) { + mockMvc = new MockMvc(backend); + } + return mockMvc; + } + + public TestRestTemplate restTemplate() { + if (restTemplate == null) { + // The scheme and address the server really listens on: https when it + // speaks TLS, and a DEFINED_PORT test's cn1.server.address when that + // binds one interface (a wildcard bind is reached at loopback). + String scheme = backend.getServer() != null && backend.getServer().isSecure() + ? "https" : "http"; + restTemplate = new TestRestTemplate(scheme + "://" + backend.getListenAddress() + ":" + port()); + } + return restTemplate; + } +} diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/TestRun.java b/vm/backend/test/src/com/codename1/impl/backend/test/TestRun.java new file mode 100644 index 00000000000..aa7b53269d6 --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestRun.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.backend.test; + +/// The report of a compiled test run, written for the build to read back. +/// +/// The generated runners call this around each test; the goal that ran the binary +/// parses the lines into Surefire's XML. One line per event, fields separated by +/// a tab, with tabs and line breaks in a message escaped: +/// +/// ``` +/// CN1TEST PASS com.example.ApiTest greets 12 +/// CN1TEST FAIL com.example.ApiTest refuses 3 java.lang.AssertionError Status expected:<400> but was:<200> +/// CN1TEST SKIP com.example.ApiTest later 0 disabled +/// ``` +public final class TestRun { + private static int passed; + private static int failed; + private static int skipped; + + private TestRun() { + } + + /// The clock reading a test started at. + public static long started() { + return System.currentTimeMillis(); + } + + /// Records the outcome of one test: passed when `failure` is null, skipped when + /// it is an aborted assumption, failed otherwise. + public static void finished(String cls, String method, long started, Throwable failure) { + long millis = System.currentTimeMillis() - started; + if (failure == null) { + passed++; + line("PASS", cls, method, millis, null, null); + } else if (isAbort(failure)) { + skipped++; + line("SKIP", cls, method, millis, failure.getClass().getName(), failure.getMessage()); + } else { + failed++; + line("FAIL", cls, method, millis, failure.getClass().getName(), describe(failure)); + } + } + + /// Records a test that did not run, and why. + public static void skipped(String cls, String method, String reason) { + skipped++; + line("SKIP", cls, method, 0, "skipped", reason); + } + + /// Records a class-level failure -- an `@BeforeAll` or `@AfterAll` that threw. + public static void lifecycleFailed(String cls, String phase, Throwable failure) { + failed++; + line("FAIL", cls, phase, 0, failure.getClass().getName(), describe(failure)); + } + + /// Prints the totals and returns the number of failures. + public static int finish() { + System.out.println("CN1TEST\tDONE\t" + passed + "\t" + failed + "\t" + skipped); + System.out.flush(); + return failed; + } + + /// Whether `failure` is an assumption that did not hold -- a skip, not a failure. + public static boolean isAbort(Throwable failure) { + // By name, so the same check holds against opentest4j on the JVM and the + // translated shim; both define it under this name. + for (Class c = failure.getClass(); c != null; c = c.getSuperclass()) { + if ("org.opentest4j.TestAbortedException".equals(c.getName())) { + return true; + } + } + return false; + } + + private static String describe(Throwable failure) { + StringBuilder sb = new StringBuilder(String.valueOf(failure.getMessage())); + Throwable cause = failure.getCause(); + int depth = 0; + while (cause != null && cause != failure && depth++ < 5) { //NOPMD CompareObjectsWithEquals - a self-caused throwable + sb.append(" | caused by ").append(cause.getClass().getName()).append(": ") + .append(cause.getMessage()); + cause = cause.getCause(); + } + return sb.toString(); + } + + private static void line(String kind, String cls, String method, long millis, String type, + String message) { + StringBuilder sb = new StringBuilder("CN1TEST\t").append(kind).append('\t').append(cls) + .append('\t').append(escape(method)).append('\t').append(millis); + if (type != null) { + sb.append('\t').append(type).append('\t').append(escape(message)); + } + System.out.println(sb.toString()); + System.out.flush(); + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + StringBuilder sb = new StringBuilder(value.length()); + for (int iter = 0 ; iter < value.length() ; iter++) { + char c = value.charAt(iter); + if (c == '\\') { + sb.append("\\\\"); + } else if (c == '\t') { + sb.append("\\t"); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else { + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/vm/backend/test/src/com/codename1/impl/backend/test/package-info.java b/vm/backend/test/src/com/codename1/impl/backend/test/package-info.java new file mode 100644 index 00000000000..f08951e7885 --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The runtime behind `com.codename1.backend.test`: what the build's generated +/// test contexts, wirings and runners call. Not API; it changes with the build. +package com.codename1.impl.backend.test; diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java index 8fc45bb5569..3c6abdce945 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendHttpIntegrationTest.java @@ -1238,6 +1238,43 @@ private byte[] chunkedWithSize(byte[] size) throws IOException { return out.toByteArray(); } + @Test + @DisplayName("binary, gzip and multipart bodies reach the handler as sent") + void binaryGzipAndMultipartBodies() throws Exception { + // A binary type keeps its bytes; it used to be a 400 for not being UTF-8. + byte[] png = new byte[] { (byte) 0x89, 'P', 'N', 'G', 0, (byte) 0xff, (byte) 0xc3, '(' }; + int sum = 0; + for (byte b : png) { + sum = sum * 31 + (b & 0xff); + } + String answer = new String(raw("POST /bodyecho HTTP/1.1\r\nHost: x\r\nContent-Type: " + + "image/png\r\nContent-Length: " + png.length + + "\r\nConnection: close\r\n\r\n", png), StandardCharsets.UTF_8); + assertTrue(answer.contains("length=8 sum=" + sum), answer); + + // A gzip body is inflated by the pure-Java JZlib the client also uses. + ByteArrayOutputStream zipped = new ByteArrayOutputStream(); + java.util.zip.GZIPOutputStream gz = new java.util.zip.GZIPOutputStream(zipped); + gz.write("{\"field\":\"zipped\"}".getBytes(StandardCharsets.UTF_8)); + gz.close(); + byte[] z = zipped.toByteArray(); + answer = new String(raw("POST /bodyecho HTTP/1.1\r\nHost: x\r\nContent-Type: " + + "application/json\r\nContent-Encoding: gzip\r\nContent-Length: " + z.length + + "\r\nConnection: close\r\n\r\n", z), StandardCharsets.UTF_8); + assertTrue(answer.contains("length=18"), answer); + + // A multipart form splits into parts; its text field is a parameter. + String boundary = "nativeb0undary"; + byte[] form = ("--" + boundary + "\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\n" + + "value\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"up\"; " + + "filename=\"f.bin\"\r\nContent-Type: application/octet-stream\r\n\r\nABCD\r\n--" + + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8); + answer = new String(raw("POST /bodyecho HTTP/1.1\r\nHost: x\r\nContent-Type: " + + "multipart/form-data; boundary=" + boundary + "\r\nContent-Length: " + + form.length + "\r\nConnection: close\r\n\r\n", form), StandardCharsets.UTF_8); + assertTrue(answer.contains("part=field/null/5 part=up/f.bin/4 field=value"), answer); + } + @Test @DisplayName("a chunked body that is not UTF-8 is refused too") void malformedUtf8ChunkedBodiesAreRefused() throws Exception { diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java index fa9e35125a6..eb899e84cf3 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendOtelTest.java @@ -109,13 +109,13 @@ void tracesOnThePackagedRuntime() throws Exception { // The same claim for the server's own endpoints, which the docs make: the // generated entry point names them only when the build asked, and a // server that never did must not carry them. - assertTrue(symbols(traced, "com_codename1_backend_Management_") > 0 - && symbols(traced, "com_codename1_backend_mcp_McpServer_") > 0, + assertTrue(symbols(traced, "com_codename1_impl_backend_Management_") > 0 + && symbols(traced, "com_codename1_impl_backend_mcp_McpServer_") > 0, "the control links management and MCP and carries neither symbol; the " + "check below would be vacuous"); - assertEquals(0, symbols(untraced, "com_codename1_backend_Management_"), + assertEquals(0, symbols(untraced, "com_codename1_impl_backend_Management_"), "a server that never asked for the management endpoints links them anyway"); - assertEquals(0, symbols(untraced, "com_codename1_backend_mcp_McpServer_"), + assertEquals(0, symbols(untraced, "com_codename1_impl_backend_mcp_McpServer_"), "a server that never asked for MCP links the endpoint anyway"); final List exports = Collections.synchronizedList(new ArrayList()); @@ -285,14 +285,24 @@ private static int otelSymbols(Path binary) throws Exception { return symbols(binary, "com_codename1_backend_otel_"); } - /** How many of a binary's symbols contain `prefix`. */ + /** + * How many of a binary's symbols BELONG to a class under `prefix`: its methods, + * fields, constructors and class records. A symbol's own class is the first + * `com_codename1` name in it -- `__NEW_`, `STATIC_FIELD_` and the like come + * before it -- while a parameter type comes later, inside a method's name. Counting + * every occurrence counted the BackendAccess.mcp bridge, whose signature names + * McpServer.Extension, as McpServer being linked. + */ private static int symbols(Path binary, String prefix) throws Exception { String symbols = BackendTestSupport.run(Arrays.asList("nm", binary.toString()), 120); int count = 0; - int at = 0; - while ((at = symbols.indexOf(prefix, at)) >= 0) { - count++; - at++; + for (String line : symbols.split("\n")) { + String trimmed = line.trim(); + String name = trimmed.substring(trimmed.lastIndexOf(' ') + 1); + int owner = name.indexOf("com_codename1_"); + if (owner >= 0 && name.startsWith(prefix, owner)) { + count++; + } } return count; } diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java index 9ad256f75b6..9d8c32e08bf 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendTestSupport.java @@ -109,6 +109,34 @@ static String build(String mainClass, String demoDir, Path binary, Path jdk8) th + (log.length() > 3000 ? log.substring(log.length() - 3000) : log); } + /** + * Translates the CI test server, scripts/hellocodenameone/backend, through its + * own `server.sh build --native` -- the Maven plugin path an application takes -- + * and copies the binary to `binary`. Returns null on success, or the reason. + */ + static String buildCn1ssServer(Path binary) throws Exception { + Path app = Paths.get("..", "..", "scripts", "hellocodenameone", "backend") + .normalize().toAbsolutePath(); + if (!Files.isRegularFile(app.resolve("server.sh"))) { + return "scripts/hellocodenameone/backend is not present"; + } + ProcessBuilder build = new ProcessBuilder("bash", app.resolve("server.sh").toString(), + "build", "--native"); + build.directory(app.toFile()); + Cn1ssTestServer.serverShEnvironment(build.environment()); + build.redirectErrorStream(true); + Process p = build.start(); + boolean[] timedOut = new boolean[1]; + String log = awaitOutput(p, 30, TimeUnit.MINUTES, timedOut); + Path built = app.resolve("target").resolve("hellocodenameone-backend"); + if (!timedOut[0] && p.exitValue() == 0 && Files.isExecutable(built)) { + Files.copy(built, binary, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + return null; + } + return "could not build the cn1ss test server:\n" + + (log.length() > 3000 ? log.substring(log.length() - 3000) : log); + } + static Process start(Path binary, Map env, Path logFile) throws IOException { ProcessBuilder run = new ProcessBuilder(binary.toString()); run.environment().putAll(env); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendWebSocketIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendWebSocketIntegrationTest.java index ba2e35c850f..2bd5ce65c96 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BackendWebSocketIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendWebSocketIntegrationTest.java @@ -35,7 +35,6 @@ import java.net.Socket; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.security.MessageDigest; import java.util.Base64; import java.util.Random; @@ -85,42 +84,27 @@ static void startServer() throws Exception { BackendTestSupport.skipOrFail(skipReason); return; } - Path backend = Paths.get("..", "backend").normalize().toAbsolutePath(); - BackendTestSupport.require(Files.isDirectory(backend), "vm/backend is not present"); - Path jdk8 = BackendTestSupport.findJdk8(); - BackendTestSupport.require(jdk8 != null, "no JDK 8 available to compile the backend"); - work = Files.createTempDirectory("backend-websocket"); outDir = Files.createDirectories(work.resolve("screenshots")); - Path binary = work.resolve("cn1ss"); // The screenshot server rather than a purpose-built fixture: it is what CI // actually runs, so a break here is a break in the transport every // on-device suite depends on, and the application protocol gets covered - // alongside the frame layer. - String failure = BackendTestSupport.build("Cn1ssScreenshotServer", "demo/cn1ss", - binary, jdk8); + // alongside the frame layer. Its own tests, compiled the same way, run in + // the backend-compiled-tests job. + Path binary = work.resolve("cn1ss"); + String failure = BackendTestSupport.buildCn1ssServer(binary); if (failure != null) { BackendTestSupport.skipOrFail(failure); return; } - // Proves the binary works before any test blames the protocol for a - // toolchain problem. - ProcessBuilder check = new ProcessBuilder(binary.toString(), "--port", "0", - "--out", outDir.toString(), "--selfcheck"); - check.redirectErrorStream(true); - Process checked = check.start(); - assertTrue(checked.waitFor(60, java.util.concurrent.TimeUnit.SECONDS), - "the self-check did not finish"); - assertEquals(0, checked.exitValue(), "the translated server failed its own self-check"); - port = BackendTestSupport.freePort(); - // Started directly rather than through BackendTestSupport.start, which - // takes no arguments: --port and --out reach the translated main through - // cn1MainArgs, and this test is partly about that being true. - ProcessBuilder run = new ProcessBuilder(binary.toString(), "--port", - String.valueOf(port), "--out", outDir.toString()); + // The port and the output directory reach the translated server through + // the environment, which its compiled-in application.properties defers to. + ProcessBuilder run = new ProcessBuilder(binary.toString()); + run.environment().put("CN1_SERVER_PORT", String.valueOf(port)); + run.environment().put("CN1SS_OUT", outDir.toString()); // A short HTTP read deadline, so idleLongerThanTheRequestTimeout can wait // past it in seconds rather than in the default fifteen. It also makes the // test sharper: the websocket allowance it must NOT inherit is now four diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CatchRethrowIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CatchRethrowIntegrationTest.java new file mode 100644 index 00000000000..1ba26624fb1 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CatchRethrowIntegrationTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.params.ParameterizedTest; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An exception thrown from a catch handler leaves the whole try statement: the + * statement's other handlers do not see it. + * + * A try with two catch clauses registers two try blocks, one per handler, and + * throwException used to pop only the block that matched -- so when that handler + * threw (a rethrow, or a new exception), its sibling handler for the SAME try was + * still registered and caught it. {@code catch (IOException e) { throw e; } catch + * (Exception e) { ... }} swallowed the rethrow on every native target; JUnit's + * assertAll stand-in, which rethrows an OutOfMemoryError ahead of a catch + * (Throwable), is how it surfaced. + */ +class CatchRethrowIntegrationTest { + + @ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + void anExceptionFromAHandlerLeavesTheTryStatement(CompilerHelper.CompilerConfig config) throws Exception { + Parser.cleanup(); + + Path sourceDir = Files.createTempDirectory("rethrow-sources"); + Path classesDir = Files.createTempDirectory("rethrow-classes"); + Path javaApiDir = Files.createTempDirectory("rethrow-java-api"); + Files.write(sourceDir.resolve("RethrowApp.java"), source().getBytes(StandardCharsets.UTF_8)); + + JavascriptTargetIntegrationTest.compileAgainstJavaApi(config, sourceDir, classesDir, javaApiDir); + + Path outputDir = Files.createTempDirectory("rethrow-output"); + CleanTargetIntegrationTest.runTranslator(classesDir, outputDir, "RethrowApp"); + + Path distDir = outputDir.resolve("dist"); + Path cmakeLists = distDir.resolve("CMakeLists.txt"); + assertTrue(Files.exists(cmakeLists), "Translator should emit a CMake project"); + CleanTargetIntegrationTest.replaceLibraryWithExecutableTarget(cmakeLists, "RethrowApp-src"); + + Path buildDir = distDir.resolve("build"); + Files.createDirectories(buildDir); + List configure = new java.util.ArrayList<>(Arrays.asList( + "cmake", "-S", distDir.toString(), "-B", buildDir.toString(), + "-DCMAKE_BUILD_TYPE=Release")); + configure.addAll(CompilerHelper.cmakeToolchainArgs()); + CleanTargetIntegrationTest.runCommand(configure, distDir); + CleanTargetIntegrationTest.runCommand(Arrays.asList("cmake", "--build", buildDir.toString()), distDir); + + Path executable = buildDir.resolve(CompilerHelper.executableName("RethrowApp")); + String output = CleanTargetIntegrationTest.runCommand(Arrays.asList(executable.toString()), buildDir); + assertTrue(output.contains("RESULT=127"), + "an exception thrown from a catch handler was caught by a sibling handler:\n" + output); + } + + private static String source() { + return "public class RethrowApp {\n" + + " static int cleanups;\n" + + " static void fail(String why) { throw new IllegalStateException(why); }\n" + // A typed handler rethrows; the broader sibling must not see it. + + " static int rethrow() {\n" + + " try { fail(\"a\"); } catch (IllegalStateException e) { throw e; }\n" + + " catch (RuntimeException e) { return 100; }\n" + + " return 200;\n" + + " }\n" + // A typed handler throws something new; same. + + " static int throwNew() {\n" + + " try { fail(\"b\"); } catch (IllegalStateException e) {\n" + + " throw new UnsupportedOperationException(\"from the handler\"); }\n" + + " catch (RuntimeException e) { return 100; }\n" + + " return 200;\n" + + " }\n" + // The same inside a try/finally: the finally runs once. + + " static int withFinally() {\n" + + " try {\n" + + " try { fail(\"c\"); } catch (IllegalStateException e) { throw e; }\n" + + " catch (RuntimeException e) { return 100; }\n" + + " } finally { cleanups++; }\n" + + " return 200;\n" + + " }\n" + // A handler of an inner try that throws is still caught by an OUTER try. + + " static int nested() {\n" + + " try {\n" + + " try { fail(\"d\"); } catch (IllegalStateException e) { throw e; }\n" + + " catch (RuntimeException e) { return 100; }\n" + + " } catch (IllegalStateException outer) { return 7; }\n" + + " return 200;\n" + + " }\n" + // SelfTest's long-skipped VM probe: a catch around a try/finally in + // the same method must see what the finally rethrows. + + " static int touched;\n" + + " static String nestedFinally() {\n" + + " try {\n" + + " try { throw new java.io.IOException(\"thrown in the inner try\"); }\n" + + " finally { touched++; }\n" + + " } catch (Exception caught) { return \"caught\"; }\n" + + " }\n" + + " public static void main(String[] args) {\n" + + " int score = 0;\n" + + " try { rethrow(); } catch (IllegalStateException e) {\n" + + " if (\"a\".equals(e.getMessage())) { score |= 1; } }\n" + + " try { throwNew(); } catch (UnsupportedOperationException e) { score |= 2; }\n" + + " try { withFinally(); } catch (IllegalStateException e) { score |= 4; }\n" + + " if (cleanups == 1) { score |= 8; }\n" + + " if (nested() == 7) { score |= 16; }\n" + + " try { fail(\"e\"); } catch (RuntimeException e) { score |= 32; }\n" + + " if (\"caught\".equals(nestedFinally()) && touched == 1) { score |= 64; }\n" + + " System.out.println(\"RESULT=\" + score);\n" + + " }\n" + + "}\n"; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java index 43f425e3363..1e5cf3421ad 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java @@ -925,6 +925,35 @@ static void extractJar(Path jar, Path dest) throws Exception { } } + /** The annotation-framework bootstraps a build can generate, in Executor's order. */ + static final String[] ANNOTATION_BOOTSTRAPS = {"MapperBootstrap", "BinderBootstrap", "DaoBootstrap", + "RestClientBootstrap", "ProtoBootstrap", "GrpcClientBootstrap", "GraphQLClientBootstrap", + "IntentBootstrap", "TelemetryBootstrap"}; + + /** hellocodenameone-common's compiled classes, which the suite launchers are built against. */ + static Path helloCommonClasses() { + return Paths.get("..", "..", "scripts", "hellocodenameone", "common", "target", "classes") + .normalize().toAbsolutePath(); + } + + /** + * Launcher lines instantiating the cn1app bootstraps present in {@code appClasses} -- + * the typed {@code @RestClient}, {@code @Mapped}, {@code @Entity} and the rest -- as + * Executor.annotationFrameworksInstallSource writes them into a real build's stub. The + * suites build their own launchers, so without these the features compile in and do + * nothing: RestClients.create threw "No RestClient impl registered". Only those present, + * because a launcher naming an absent class does not compile. + */ + static String annotationBootstrapLines(Path appClasses) { + StringBuilder sb = new StringBuilder(); + for (String boot : ANNOTATION_BOOTSTRAPS) { + if (Files.exists(appClasses.resolve("cn1app/" + boot + ".class"))) { + sb.append(" new cn1app.").append(boot).append("();\n"); + } + } + return sb.toString(); + } + /** Launcher main that drives the hellocodenameone Cn1ssDeviceRunner screenshot suite on the Windows port. */ static String winHelloLauncherSource() { return "import com.codename1.ui.Display;\n" + @@ -998,6 +1027,7 @@ static String winHelloLauncherSource() { // app's gradient/SVG/Button UIIDs on top. Without this the suite // renders on the bare app theme (no component styling, no dark mode). // initFirstTheme also sets the global Resources so getImage() works. + annotationBootstrapLines(helloCommonClasses()) + " com.codename1.ui.plaf.UIManager.initFirstTheme(\"/theme\");\n" + " TestReporting.setInstance(new Cn1ssDeviceRunnerReporter());\n" + // KotlinUiTest is registered as the prepended test exactly as the @@ -1265,7 +1295,7 @@ static String winShotAppSource(String pngPath, int width, int height) { /** * Full native-Windows screenshot suite over the cn1ss WebSocket: builds the * hellocodenameone exe (the same one scripts/windows/run-hello.bat runs), - * starts a Cn1ssScreenshotServer on 8765 (the port the device runner defaults + * starts the CI test server on 8765 (the port the device runner defaults * to), launches the exe, and waits for the CN1SS:SUITE:FINISHED marker on its * stdout before tearing the process down. Asserts the suite finished and that * a healthy number of PNGs landed. This is the CI counterpart of run-hello.bat. @@ -1303,27 +1333,15 @@ void capturesHelloSuiteOverWebSocket() throws Exception { + " (" + Files.size(performanceBinary) + " bytes)"); } - // Compile the shared cn1ss screenshot server with an available JDK. + // Start the CI test server with an available JDK: screenshots in, REST out. java.util.List configs = new java.util.ArrayList<>(); for (String v : new String[] { "17", "21", "25", "11", "1.8" }) { configs.addAll(CompilerHelper.getAvailableCompilers(v)); } CompilerHelper.CompilerConfig jdk = configs.get(0); - Path serverSrc = Paths.get("..", "..", "scripts", "common", "java", "Cn1ssScreenshotServer.java") - .normalize().toAbsolutePath(); - Path serverClasses = Files.createTempDirectory("cn1ss-server"); - assertEquals(0, CompilerHelper.compile(jdk.jdkHome, Arrays.asList( - "-d", serverClasses.toString(), "-sourcepath", serverSrc.getParent().toString(), - serverSrc.toString())), "Cn1ssScreenshotServer should compile"); - - // The device runner defaults to ws://127.0.0.1:8765 (Cn1ssDeviceRunnerHelper). int port = 8765; Path outDir = Files.createTempDirectory("cn1ss-hello-out"); - String javaBin = jdk.jdkHome.resolve("bin").resolve(CompilerHelper.executableName("java")).toString(); - ProcessBuilder serverPb = new ProcessBuilder(javaBin, "-cp", serverClasses.toString(), - "Cn1ssScreenshotServer", "--port", String.valueOf(port), "--out", outDir.toString()); - serverPb.redirectErrorStream(true); - Process server = serverPb.start(); + Process server = Cn1ssTestServer.processBuilder(jdk.jdkHome, port, outDir).start(); Process app = null; try { final java.util.concurrent.CountDownLatch ready = new java.util.concurrent.CountDownLatch(1); @@ -1335,7 +1353,7 @@ public void run() { String line; while ((line = r.readLine()) != null) { synchronized (serverLog) { serverLog.append(line).append('\n'); } - if (line.contains("CN1SS_SERVER_PORT")) { ready.countDown(); } + if (line.contains(Cn1ssTestServer.READY)) { ready.countDown(); } } } catch (IOException ignore) { } @@ -1343,8 +1361,14 @@ public void run() { }); sreader.setDaemon(true); sreader.start(); - assertTrue(ready.await(30, java.util.concurrent.TimeUnit.SECONDS), - "cn1ss server should start listening"); + // With the server's own output when it does not: a build or start-up + // failure in it otherwise read as a bare timeout. + if (!ready.await(900, java.util.concurrent.TimeUnit.SECONDS)) { + String log; + synchronized (serverLog) { log = serverLog.toString(); } + org.junit.jupiter.api.Assertions.fail("cn1ss server should start listening; it printed:\n" + + (log.length() > 6000 ? log.substring(log.length() - 6000) : log)); + } // Launch the suite exe and watch its stdout for the end marker. The // exe never self-exits (it owns the Win32 message pump), so we kill it diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java index 7ac8d05b450..327e753283f 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetLinuxIntegrationTest.java @@ -84,6 +84,10 @@ static String linuxHelloLauncherSource() { // Install the transcoded SVG/Lottie registry before the first theme so // url(*.svg) backgrounds resolve, then load the app theme. " try { com.codename1.generated.svg.SVGRegistry.installGlobal(); } catch (Throwable __svg) { __svg.printStackTrace(); }\n" + + // The generated annotation bootstraps (the typed REST client and the rest), + // as a real build's stub instantiates them; see annotationBootstrapLines. + CleanTargetIntegrationTest.annotationBootstrapLines( + CleanTargetIntegrationTest.helloCommonClasses()) + " com.codename1.ui.plaf.UIManager.initFirstTheme(\"/theme\");\n" + " TestReporting.setInstance(new Cn1ssDeviceRunnerReporter());\n" + " Cn1ssDeviceRunner.addTest(new com.codenameone.examples.hellocodenameone.tests.KotlinUiTest());\n" + @@ -361,26 +365,15 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { elf = buildHelloCodenameOneElf(); } - // Compile + start the shared cn1ss WebSocket screenshot server. + // Start the CI test server: screenshots in over the websocket, REST out. List configs = new ArrayList<>(); for (String v : new String[] { "17", "21", "25", "11", "1.8" }) { configs.addAll(CompilerHelper.getAvailableCompilers(v)); } CompilerHelper.CompilerConfig jdk = configs.get(0); - Path serverSrc = Paths.get("..", "..", "scripts", "common", "java", "Cn1ssScreenshotServer.java") - .normalize().toAbsolutePath(); - Path serverClasses = Files.createTempDirectory("cn1ss-server"); - assertEquals(0, CompilerHelper.compile(jdk.jdkHome, Arrays.asList( - "-d", serverClasses.toString(), "-sourcepath", serverSrc.getParent().toString(), - serverSrc.toString())), "Cn1ssScreenshotServer should compile:\n" - + CompilerHelper.getLastErrorLog()); - int port = 8765; Path outDir = Files.createTempDirectory("cn1ss-linux-out"); - String javaBin = jdk.jdkHome.resolve("bin").resolve("java").toString(); - Process server = new ProcessBuilder(javaBin, "-cp", serverClasses.toString(), - "Cn1ssScreenshotServer", "--port", String.valueOf(port), "--out", outDir.toString()) - .redirectErrorStream(true).start(); + Process server = Cn1ssTestServer.processBuilder(jdk.jdkHome, port, outDir).start(); Process app = null; try { final CountDownLatch ready = new CountDownLatch(1); @@ -390,14 +383,21 @@ void capturesHelloSuiteOverWebSocketLinux() throws Exception { String line; while ((line = r.readLine()) != null) { synchronized (serverLog) { serverLog.append(line).append('\n'); } - if (line.contains("CN1SS_SERVER_PORT")) { ready.countDown(); } + if (line.contains(Cn1ssTestServer.READY)) { ready.countDown(); } } } catch (IOException ignore) { } }); sreader.setDaemon(true); sreader.start(); - assertTrue(ready.await(30, TimeUnit.SECONDS), "cn1ss server should start listening"); + // With the server's own output when it does not: a build or start-up + // failure in it otherwise read as a bare timeout. + if (!ready.await(900, TimeUnit.SECONDS)) { + String log; + synchronized (serverLog) { log = serverLog.toString(); } + org.junit.jupiter.api.Assertions.fail("cn1ss server should start listening; it printed:\n" + + (log.length() > 6000 ? log.substring(log.length() - 6000) : log)); + } ProcessBuilder appPb = new ProcessBuilder(elf.toAbsolutePath().toString()); appPb.directory(elf.getParent().toFile()); diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/Cn1ssTestServer.java b/vm/tests/src/test/java/com/codename1/tools/translator/Cn1ssTestServer.java new file mode 100644 index 00000000000..f99c8432817 --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/Cn1ssTestServer.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2012, 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +/** + * Starts the CI test server, scripts/hellocodenameone/backend, for the native + * desktop suites: it receives their screenshots over the websocket the device side + * dials (ws://127.0.0.1:8765) and serves the REST surface their networking tests + * drive. + * + * Two ways, chosen by the environment. With {@code CN1SS_SERVER_DIST} naming a copy + * made by {@code server.sh dist} -- what a job with no Maven repository of its own + * is handed, such as the Windows run -- it is plain java over that directory. + * Otherwise {@code server.sh run} builds it here and runs it. + */ +final class Cn1ssTestServer { + /** What the server prints once it accepts connections. */ + static final String READY = "listening on http port"; + + private Cn1ssTestServer() { + } + + /** + * What server.sh needs from a test JVM's environment. The tests module names + * its JDKs JDK_17_HOME and so on, where server.sh reads JAVA17_HOME; and a + * checkout's own .m2-repo, when it has one, is where CI installed the backend + * runtime and where a developer's concurrent checkouts cannot clobber it. + */ + static void serverShEnvironment(java.util.Map env) { + String jdk17 = System.getenv("JDK_17_HOME"); + if (env.get("JAVA17_HOME") == null && jdk17 != null && !jdk17.trim().isEmpty()) { + env.put("JAVA17_HOME", jdk17.trim()); + } + Path m2 = Paths.get("..", "..", ".m2-repo").toAbsolutePath().normalize(); + String opts = env.get("MAVEN_OPTS"); + if (Files.isDirectory(m2) && (opts == null || !opts.contains("maven.repo.local"))) { + env.put("MAVEN_OPTS", (opts == null ? "" : opts + " ") + "-Dmaven.repo.local=" + m2); + } + } + + static ProcessBuilder processBuilder(Path jdkHome, int port, Path outDir) throws Exception { + Path repo = Paths.get("..", "..").toAbsolutePath().normalize(); + ProcessBuilder pb; + String dist = System.getenv("CN1SS_SERVER_DIST"); + if (dist != null && !dist.trim().isEmpty()) { + Path root = Paths.get(dist.trim()).toAbsolutePath(); + String main = new String(Files.readAllBytes(root.resolve("classes/META-INF/cn1-backend-main")), + "UTF-8").trim(); + String java = jdkHome.resolve("bin").resolve(CompilerHelper.executableName("java")).toString(); + pb = new ProcessBuilder(java, "-cp", root.resolve("classes") + File.pathSeparator + + root.resolve("lib") + File.separator + "*", main); + pb.directory(repo.resolve("scripts/hellocodenameone/backend").toFile()); + } else { + pb = new ProcessBuilder(Arrays.asList("bash", + repo.resolve("scripts/hellocodenameone/backend/server.sh").toString(), + "run", "--jvm", "--port", String.valueOf(port), "--out", outDir.toString())); + pb.environment().put("JAVA_HOME", jdkHome.toString()); + serverShEnvironment(pb.environment()); + } + pb.environment().put("CN1SS_OUT", outDir.toString()); + pb.environment().put("CN1_SERVER_PORT", String.valueOf(port)); + pb.redirectErrorStream(true); + return pb; + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java new file mode 100644 index 00000000000..c871cfcc87d --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java @@ -0,0 +1,270 @@ +/* + * 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/// The JavaScript port's networking, at the two points where a byte or a header used to be lost +/// without any error: response headers read through the JSO bridge, and response bytes copied from +/// a `Uint8Array` into a Java `byte[]`. +/// +/// Both were found by the sample app's backend tests: a cross-origin POST whose exposed `X-Probe` +/// header read back null, and a 100000-byte download that read back with the right length and the +/// wrong contents. Each is checked here by running the real `parparvm_runtime.js` and `port.js` +/// under Node, so no browser and no translated application is needed. +class JavascriptNetworkBindingsTest { + + private static final Path RUNTIME = Paths.get("..", "ByteCodeTranslator", "src", "javascript", + "parparvm_runtime.js").toAbsolutePath().normalize(); + private static final Path PORT_ROOT = + Paths.get("..", "..", "Ports", "JavaScriptPort").toAbsolutePath().normalize(); + private static final Path PORT_JS = PORT_ROOT.resolve(Paths.get("src", "main", "webapp", "port.js")); + private static final Path NETWORK_CONNECTION = PORT_ROOT.resolve(Paths.get("src", "main", "java", + "com", "codename1", "impl", "html5", "NetworkConnection.java")); + + /// The bridge maps a no-argument `getXxx()` to a property read, which is why + /// `XMLHttpRequest.getAllResponseHeaders()` cannot be called through it. If the bridge ever + /// learns to call it as a method this fails, and the `@JSBody` workaround can go. + @Test + void bridgeReadsGetAllResponseHeadersAsAProperty() throws Exception { + String out = runNode("" + + "const b = jvm.parseJsoBridgeMethod('com_codename1_html5_js_ajax_XMLHttpRequest'," + + " 'cn1_com_codename1_html5_js_ajax_XMLHttpRequest_getAllResponseHeaders_R_java_lang_String');\n" + + "console.log('KIND=' + b.kind + ' MEMBER=' + b.member);\n"); + assertTrue(out.contains("KIND=getter MEMBER=allResponseHeaders"), + "the bridge no longer maps getAllResponseHeaders() to a property read: " + out); + } + + /// No JavaScript-port source calls `getAllResponseHeaders()` on a JSO, which answers null for + /// every response (see above). The call inside a `@JSBody` script is a string and is allowed. + @Test + void noPortSourceCallsGetAllResponseHeadersThroughTheBridge() throws Exception { + Pattern call = Pattern.compile("\\.getAllResponseHeaders\\s*\\(\\s*\\)"); + StringBuilder offenders = new StringBuilder(); + int scanned = 0; + try (Stream paths = Files.walk(PORT_ROOT.resolve(Paths.get("src", "main", "java")))) { + for (Path path : (Iterable) paths.filter(p -> p.toString().endsWith(".java"))::iterator) { + scanned++; + String code = stripCommentsAndStrings(read(path)); + if (call.matcher(code).find()) { + offenders.append(' ').append(PORT_ROOT.relativize(path)); + } + } + } + assertTrue(scanned > 100, "the JavaScript port sources were not found under " + PORT_ROOT); + assertEquals("", offenders.toString(), + "these call XMLHttpRequest.getAllResponseHeaders() through the JSO bridge, which reads " + + "a property that does not exist and answers null; use NetworkConnection.responseHeaders"); + } + + /// The `@JSBody` that replaces it really invokes the method, and tolerates a missing receiver. + @Test + void responseHeadersScriptInvokesTheMethod() throws Exception { + String source = read(NETWORK_CONNECTION); + Matcher m = Pattern.compile("@JSBody\\(params=\\{\"xhr\"\\}, script=((?:\\s*\\+?\\s*\"[^\"]*\")+)\\)" + + "\\s*static native String responseHeaders\\(XMLHttpRequest xhr\\);").matcher(source); + assertTrue(m.find(), "the responseHeaders @JSBody was not found in NetworkConnection.java"); + StringBuilder script = new StringBuilder(); + Matcher literal = Pattern.compile("\"([^\"]*)\"").matcher(m.group(1)); + while (literal.find()) { + script.append(literal.group(1)); + } + String out = runNode("" + + "const f = new Function('xhr', " + quoteJs(script.toString()) + ");\n" + + "const xhr = { getAllResponseHeaders: function() {" + + " return 'content-type: application/json\\r\\nx-probe: echo\\r\\n'; } };\n" + + "console.log('HEADERS=' + JSON.stringify(f(xhr)));\n" + + "console.log('NULL=' + f(null));\n"); + assertTrue(out.contains("HEADERS=\"content-type: application/json\\r\\nx-probe: echo\\r\\n\""), + "the @JSBody did not return the XHR's header block: " + out); + assertTrue(out.contains("NULL=null"), "the @JSBody must answer null without a request: " + out); + } + + /// The send `@JSBody` applies the request's timeout and reports a timeout -- which a + /// synchronous XHR throws -- as a failure string instead of letting it escape. + @Test + void sendScriptAppliesTheTimeoutAndReportsTimingOut() throws Exception { + String source = read(NETWORK_CONNECTION); + Matcher m = Pattern.compile("@JSBody\\(params=\\{\"xhr\", \"body\", \"timeoutMillis\"\\}, " + + "script=((?:\\s*\\+?\\s*\"[^\"]*\")+)\\)\\s*static native String send\\(").matcher(source); + assertTrue(m.find(), "the send @JSBody was not found in NetworkConnection.java"); + StringBuilder script = new StringBuilder(); + Matcher literal = Pattern.compile("\"([^\"]*)\"").matcher(m.group(1)); + while (literal.find()) { + script.append(literal.group(1)); + } + String out = runNode("" + + "const f = new Function('xhr', 'body', 'timeoutMillis', " + quoteJs(script.toString()) + ");\n" + + "const slow = { timeout: 0, send: function() {" + + " const e = new Error('took too long'); e.name = 'TimeoutError'; throw e; } };\n" + + "console.log('SLOW=' + f(slow, null, 2000) + ' TIMEOUT=' + slow.timeout);\n" + + "let sent = null;\n" + + "const fast = { timeout: 0, send: function(b) { sent = b === undefined ? 'none' : b; } };\n" + + "console.log('FAST=' + f(fast, 'payload', 0) + ' SENT=' + sent + ' TIMEOUT=' + fast.timeout);\n" + + "const quiet = { timeout: 0, status: 0, response: null, send: function() {} };\n" + + "console.log('QUIET=' + f(quiet, null, 2000));\n" + + "const answered = { timeout: 0, status: 200, response: {}, send: function() {} };\n" + + "console.log('ANSWERED=' + f(answered, null, 2000));\n"); + assertTrue(out.contains("SLOW=TimeoutError: took too long TIMEOUT=2000"), + "a timeout must be applied and reported: " + out); + assertTrue(out.contains("FAST=null SENT=payload TIMEOUT=0"), + "a request without a timeout is sent as it was: " + out); + assertTrue(out.contains("QUIET=NetworkError: no response"), + "a request that came back with no response at all must be a failure: " + out); + assertTrue(out.contains("ANSWERED=null"), "an answered request is not a failure: " + out); + } + + /// `ArrayBufferInputStream.read(byte[], int, int)` stores signed Java bytes. A raw 0..255 + /// value is a byte no Java code can produce: `b == (byte) 200` is false for it. + @Test + void bulkReadStoresSignedJavaBytes() throws Exception { + String out = runNode("" + + "const id = 'cn1_com_codename1_teavm_io_ArrayBufferInputStream_readBulkImpl_" + + "com_codename1_html5_js_typedarrays_Uint8Array_int_byte_1ARRAY_int_int';\n" + + "if (typeof global[id] !== 'function') throw new Error('readBulkImpl is not bound');\n" + + "const src = jvm.wrapJsObject(Uint8Array.from([9, 0, 127, 128, 200, 255])," + + " 'com_codename1_html5_js_typedarrays_Uint8Array');\n" + + "const dst = jvm.newArray(7, 'JAVA_BYTE', 1);\n" + + "const r = global[id](src, 1, dst, 2, 5);\n" + + "if (r && typeof r.next === 'function') { r.next(); }\n" + + "console.log('BYTES=' + JSON.stringify(Array.from(dst)));\n" + // And back: the signed values become the original unsigned ones again. + + "const back = global['cn1_com_codename1_teavm_io_BlobUtil_byteArrayToUint8Array_" + + "byte_1ARRAY_R_com_codename1_html5_js_typedarrays_Uint8Array'](dst);\n" + + "console.log('BACK=' + JSON.stringify(Array.from(back.__jsValue || back)));\n"); + assertTrue(out.contains("BYTES=[0,0,0,127,-128,-56,-1]"), + "bulk read must sign-extend into the byte[] and honour both offsets: " + out); + assertTrue(out.contains("BACK=[0,0,0,127,128,200,255]"), + "byte[] to Uint8Array must restore the unsigned values: " + out); + } + + @Test + void getBytesStoresSignedJavaBytes() throws Exception { + // The same rule for the runtime's String.getBytes: "\u00e9" is C3 A9 in UTF-8, + // which a Java byte[] holds as -61 and -87. + String out = runNode("" + + "const id = 'cn1_java_lang_String_charsToBytes_char_1ARRAY_char_1ARRAY_R_byte_1ARRAY';\n" + + "if (typeof global[id] !== 'function') throw new Error('charsToBytes is not bound');\n" + + "const chars = jvm.newArray(2, 'JAVA_CHAR', 1);\n" + + "chars[0] = 65; chars[1] = 233;\n" + + "let r = global[id](chars, null);\n" + + "if (r && typeof r.next === 'function') { r = r.next().value; }\n" + + "console.log('BYTES=' + JSON.stringify(Array.from(r)));\n"); + assertTrue(out.contains("BYTES=[65,-61,-87]"), + "getBytes must sign-extend into the byte[]: " + out); + } + + private static String runNode(String body) throws Exception { + assertTrue(Files.exists(RUNTIME), "ParparVM JavaScript runtime not found at " + RUNTIME); + assertTrue(Files.exists(PORT_JS), "port.js not found at " + PORT_JS); + Path harness = Files.createTempFile("js-network-bindings", ".js"); + try { + String source = "" + + "const fs = require('fs');\n" + + "const vm = require('vm');\n" + + "global.self = global; global.window = global; global.global = global;\n" + + "global.postMessage = function() {};\n" + + "vm.runInThisContext(fs.readFileSync(" + quoteJs(RUNTIME.toString()) + ", 'utf8'));\n" + + "vm.runInThisContext(fs.readFileSync(" + quoteJs(PORT_JS.toString()) + ", 'utf8'));\n" + + body; + Files.write(harness, source.getBytes(StandardCharsets.UTF_8)); + Process process = new ProcessBuilder("node", harness.toString()).start(); + String output = readAll(process.getInputStream()); + String errors = readAll(process.getErrorStream()); + assertEquals(0, process.waitFor(), "node harness failed. stdout: " + output + " stderr: " + errors); + return output; + } finally { + Files.deleteIfExists(harness); + } + } + + /// Blanks out comments and string literals so a mention of a call in either is not a call. + private static String stripCommentsAndStrings(String code) { + StringBuilder out = new StringBuilder(code.length()); + int i = 0; + int n = code.length(); + while (i < n) { + char c = code.charAt(i); + if (c == '/' && i + 1 < n && code.charAt(i + 1) == '/') { + while (i < n && code.charAt(i) != '\n') { + i++; + } + } else if (c == '/' && i + 1 < n && code.charAt(i + 1) == '*') { + int end = code.indexOf("*/", i + 2); + i = end < 0 ? n : end + 2; + } else if (c == '"' || c == '\'') { + i++; + while (i < n && code.charAt(i) != c) { + if (code.charAt(i) == '\\') { + i++; + } + i++; + } + i++; + out.append("\"\""); + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + private static String read(Path path) throws Exception { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static String readAll(InputStream input) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + out.write(buffer, 0, count); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + + private static String quoteJs(String value) { + StringBuilder out = new StringBuilder("'"); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\' || c == '\'') { + out.append('\\'); + } + out.append(c); + } + return out.append('\'').toString(); + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptRuntimeSemanticsTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptRuntimeSemanticsTest.java index 80eed1e1e36..d38eee496a7 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptRuntimeSemanticsTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptRuntimeSemanticsTest.java @@ -46,6 +46,17 @@ void executesNativeCollectionStorageInWorkerRuntime(CompilerHelper.CompilerConfi assertTrue(result.errorMessage == null || result.errorMessage.isEmpty()); } + @ParameterizedTest + @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") + void anExceptionUnwindingThroughAFinallyReachesTheCaller(CompilerHelper.CompilerConfig config) throws Exception { + // The catch-all handler's case label used to be stripped as dead, so the + // throw vanished at the finally and the method returned normally. + WorkerRunResult result = translateAndRunFixture(config, "JsFinallyRethrowApp.java", "JsFinallyRethrowApp"); + assertEquals(31, result.result, "an exception through a finally or synchronized block was lost: " + + result.errorMessage); + assertTrue(result.errorMessage == null || result.errorMessage.isEmpty()); + } + @ParameterizedTest @org.junit.jupiter.params.provider.MethodSource("com.codename1.tools.translator.BytecodeInstructionIntegrationTest#provideCompilerConfigs") void executesArrayCovarianceInWorkerRuntime(CompilerHelper.CompilerConfig config) throws Exception { diff --git a/vm/tests/src/test/resources/com/codename1/tools/translator/JsFinallyRethrowApp.java b/vm/tests/src/test/resources/com/codename1/tools/translator/JsFinallyRethrowApp.java new file mode 100644 index 00000000000..9e1826acb04 --- /dev/null +++ b/vm/tests/src/test/resources/com/codename1/tools/translator/JsFinallyRethrowApp.java @@ -0,0 +1,116 @@ +/* + * 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. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// An exception unwinding through a finally must reach the caller. The +/// translator's dead-case pass stripped the case label of every catch-all +/// handler -- a finally, a synchronized block's release -- so the throw was +/// dispatched to a pc with no case, fell to "default: return" and vanished, and +/// the method returned normally. Shaped like ConnectionRequest's +/// performOperationComplete, where it dropped every network failure on the +/// browser: a typed catch that rethrows, inside a try/finally, around a call +/// that suspends. +public class JsFinallyRethrowApp { + public static int result; + static int cleanups; + static Object pending; + static final Object LOCK = new Object(); + + static void fail(int n) throws java.io.IOException { + Thread.yield(); + if (n >= 0) { + throw new java.io.IOException("boom " + n); + } + } + + static boolean rethrowsThroughFinally(int n) throws java.io.IOException { + String state = "start"; + try { + for (int iter = 0 ; iter < 3 ; iter++) { + state = state + iter; + Thread.yield(); + } + fail(n); + return true; + } catch (java.io.IOException err) { + if (pending != null) { + java.io.IOException other = (java.io.IOException) pending; + pending = null; + throw other; + } + throw err; + } finally { + cleanups++; + state = null; + } + } + + static boolean plainFinally(int n) throws java.io.IOException { + try { + Thread.yield(); + fail(n); + return true; + } finally { + cleanups++; + } + } + + static boolean throughSynchronized(int n) throws java.io.IOException { + synchronized (LOCK) { + Thread.yield(); + fail(n); + return true; + } + } + + public static void main(String[] args) { + int score = 0; + try { + rethrowsThroughFinally(1); + } catch (java.io.IOException expected) { + if ("boom 1".equals(expected.getMessage())) { + score |= 1; + } + } + try { + plainFinally(2); + } catch (java.io.IOException expected) { + score |= 2; + } + try { + throughSynchronized(3); + } catch (java.io.IOException expected) { + score |= 4; + } + try { + if (rethrowsThroughFinally(-1)) { + score |= 8; + } + } catch (java.io.IOException unexpected) { + score = -100; + } + if (cleanups == 3) { + score |= 16; + } + result = score; + } +}