From be108779f424a5dcc73bc5807028d513f712de07 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:25:20 +0300 Subject: [PATCH 01/12] Backend: Spring-style test framework, hellocodenameone e2e server, API cleanup - Public backend API is now what an app writes against: the Spring aliases and settings annotations are gone (application.properties is compiled in instead) and the generated-code plumbing moved to com.codename1.impl.backend, which the backend javadoc excludes. - codenameone-backend-test: @BackendTest, MockMvc, TestRestTemplate, @TestConfiguration and @MockitoBean. The same JUnit 5 tests run on the JVM and, with -Dcn1.backend.compiledTests=true, translated into a native test binary. - CORS, multipart/@RequestPart and gzip in the server. - scripts/hellocodenameone/backend replaces demo/cn1ss and the legacy Java server as the single CI server: the screenshot websocket plus the REST surface new device tests in common drive. - Archetype, initializr template, docs and CI wired to all of the above. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/ci/apple-checks.json | 8 +- .github/scripts/build_javadocs.sh | 18 +- .github/scripts/generate-quality-report.py | 3 + .github/workflows/car-android.yml | 4 +- .github/workflows/developer-guide-docs.yml | 2 +- .github/workflows/parparvm-tests-windows.yml | 38 +- .github/workflows/parparvm-tests.yml | 97 +- .github/workflows/pr.yml | 21 +- .github/workflows/scripts-android.yml | 4 +- .github/workflows/scripts-fidelity.yml | 4 +- .github/workflows/scripts-ios.yml | 8 +- .github/workflows/scripts-javascript.yml | 24 +- .github/workflows/scripts-macos.yml | 4 +- .github/workflows/windows-cross-build-run.yml | 20 +- CLAUDE.md | 3 +- .../src/com/codename1/io/gzip/Adler32.java | 1 + .../src/com/codename1/io/gzip/CRC32.java | 1 + .../src/com/codename1/io/gzip/Checksum.java | 1 + .../src/com/codename1/io/gzip/Deflate.java | 1 + .../src/com/codename1/io/gzip/Deflater.java | 1 + .../io/gzip/DeflaterOutputStream.java | 1 + .../codename1/io/gzip/FilterInputStream.java | 1 + .../codename1/io/gzip/FilterOutputStream.java | 1 + .../com/codename1/io/gzip/GZIPException.java | 1 + .../src/com/codename1/io/gzip/GZIPHeader.java | 1 + .../codename1/io/gzip/GZIPInputStream.java | 15 +- .../codename1/io/gzip/GZIPOutputStream.java | 1 + .../src/com/codename1/io/gzip/InfBlocks.java | 1 + .../src/com/codename1/io/gzip/InfCodes.java | 1 + .../src/com/codename1/io/gzip/InfTree.java | 1 + .../src/com/codename1/io/gzip/Inflate.java | 1 + .../src/com/codename1/io/gzip/Inflater.java | 1 + .../io/gzip/InflaterInputStream.java | 1 + .../src/com/codename1/io/gzip/JZlib.java | 1 + .../src/com/codename1/io/gzip/StaticTree.java | 1 + .../src/com/codename1/io/gzip/Tree.java | 1 + .../src/com/codename1/io/gzip/ZStream.java | 1 + Ports/JavaScriptPort/src/main/webapp/port.js | 6 +- docs/demos/backend/pom.xml | 20 + .../{beans/WiringTest.java => Uploads.java} | 48 +- .../backend/beans/AuditLog.java | 4 +- .../developerguide/backend/beans/Cart.java | 4 +- .../backend/beans/Checkout.java | 4 +- .../developerguide/backend/beans/Digest.java | 4 +- .../developerguide/backend/beans/Imports.java | 4 +- .../developerguide/backend/beans/Orders.java | 4 +- .../developerguide/backend/beans/Outbox.java | 4 +- .../backend/beans/ReminderService.java | 4 +- .../developerguide/backend/beans/Reports.java | 4 +- .../backend/beans/RequestClock.java | 4 +- .../developerguide/backend/beans/Signups.java | 4 +- .../backend/beans/SmtpMailer.java | 4 +- .../backend/beans/SupportTools.java | 4 +- .../backend/testing/FakeGreetingsTest.java | 62 + .../backend/testing/GreetingApi.java | 50 + .../backend/testing/GreetingApiTest.java | 55 + .../backend/testing/Greetings.java | 18 +- .../backend/testing/MockedGreetingsTest.java | 53 + .../backend/testing/ServedGreetingTest.java | 54 + .../backend/testing/TunedGreetingTest.java | 48 + .../backend-observability.properties | 6 + .../developer-guide/backend-testing.sh | 9 + .../developer-guide/backend-testing.xml | 44 + .../developer-guide/backend-web.properties | 7 + docs/developer-guide/Backend-Beans.asciidoc | 38 +- docs/developer-guide/Backend-MCP.asciidoc | 7 +- .../Backend-Observability.asciidoc | 12 +- .../Backend-Scheduling.asciidoc | 2 +- .../developer-guide/Backend-Sessions.asciidoc | 10 +- docs/developer-guide/Backend-Testing.asciidoc | 285 ++++ docs/developer-guide/Backend-Web.asciidoc | 95 +- docs/developer-guide/Backend.asciidoc | 65 +- docs/developer-guide/developer-guide.asciidoc | 2 + .../img/backend-build-pipeline.svg | 2 +- .../blog/websocket-server-tests-our-apps.md | 4 +- docs/website/data/port_status.json | 12 + maven/backend-test/pom.xml | 196 +++ maven/backend-test/spotbugs-exclude.xml | 30 + maven/backend/spotbugs-exclude.xml | 26 +- .../backend/ApplicationRuntimeTest.java | 195 +-- .../com/codename1/backend/ConfigTest.java | 20 + .../com/codename1/backend/HttpBodiesTest.java | 318 ++++ .../backend/ServerEngineTransactionsTest.java | 2 + ...DevToolsTest.java => StdioBridgeTest.java} | 30 +- .../{ => impl}/backend/JsonCodecTest.java | 3 +- .../impl/backend/SchedulerLeaseTest.java | 117 ++ .../impl/backend/mcp/DevToolsTest.java | 57 + .../backend/mcp/McpDispatchTest.java | 2 +- .../META-INF/maven/archetype-metadata.xml | 6 + .../archetype-resources/backend/pom.xml | 53 +- .../backend/src/main/java/Greeter.java | 6 +- .../backend/src/test/java/ApiTest.java | 68 + .../backend/src/test/java/ServedApiTest.java | 50 + .../resources/archetype-resources/pom.xml | 54 +- maven/codenameone-maven-plugin/pom.xml | 8 + .../maven/AbstractBackendNativeMojo.java | 1392 +++++++++++++++++ .../codename1/maven/BackendPackageMojo.java | 1311 +--------------- .../com/codename1/maven/BackendTestMojo.java | 303 ++++ .../codename1/maven/CompiledTestReport.java | 188 +++ .../maven/ProcessTestAnnotationsMojo.java | 89 ++ .../maven/processors/BackendBeans.java | 210 ++- .../maven/processors/BackendJsonCodecs.java | 2 +- .../maven/processors/BackendSettings.java | 185 +-- .../maven/processors/BackendSources.java | 33 +- .../processors/BackendTestGenerator.java | 787 ++++++++++ .../maven/processors/BackendTests.java | 102 ++ .../maven/processors/BackendWiringWriter.java | 92 +- .../maven/processors/CronCompiler.java | 2 +- .../RestControllerAnnotationProcessor.java | 257 ++- .../maven/BackendPackageEncodingTest.java | 4 +- .../BackendPackageJarDependencyTest.java | 2 +- .../maven/BackendPackageJdkFallbackTest.java | 8 +- .../maven/processors/BackendBeansTest.java | 352 +++-- .../processors/BackendTestGeneratorTest.java | 248 +++ .../cn1app-archetype-test.sh | 33 +- maven/pom.xml | 1 + scripts/check-backend-javadoc-internals.sh | 56 + scripts/check-backend-jdk-surface.py | 6 +- .../common/java/Cn1ssScreenshotServer.java | 479 ------ .../fidelity/Cn1ssDeviceRunnerHelper.java | 9 +- .../backend/application.properties | 21 + scripts/hellocodenameone/backend/pom.xml | 113 ++ scripts/hellocodenameone/backend/server.sh | 206 +++ .../backend/Cn1ssEndpoint.java | 140 ++ .../hellocodenameone/backend/Pet.java | 42 + .../hellocodenameone/backend/PetApi.java | 75 + .../hellocodenameone/backend/PetStore.java | 78 + .../hellocodenameone/backend/ProbeApi.java | 308 ++++ .../backend/ScreenshotStore.java | 174 +++ .../backend/Cn1ssEndpointTest.java | 163 ++ .../backend/FakeStoreTest.java | 67 + .../backend/MockedStoreTest.java | 63 + .../hellocodenameone/backend/PetApiTest.java | 103 ++ .../backend/ProbeApiTest.java | 139 ++ .../hellocodenameone/backend/ServedTest.java | 79 + .../tests/Cn1ssDeviceRunner.java | 13 + .../tests/Cn1ssDeviceRunnerHelper.java | 9 +- .../tests/backend/BackendClientTest.java | 143 ++ .../backend/BackendConnectionRequestTest.java | 237 +++ .../tests/backend/BackendRestTest.java | 138 ++ .../tests/backend/BackendTransferTest.java | 176 +++ .../tests/backend/BackendTypedClientTest.java | 131 ++ .../tests/backend/PetDto.java | 48 + .../tests/backend/PetsApi.java | 67 + scripts/hellocodenameone/pom.xml | 65 +- .../main/resources/agent-skill-agents-md.md | 2 +- .../main/resources/agent-skill-claude-stub.md | 2 +- .../common/src/main/resources/common.zip | Bin 259139 -> 263329 bytes .../common/src/main/resources/skill/SKILL.md | 8 +- .../resources/skill/references/backend.md | 100 +- scripts/ios/screenshots-tv/README.md | 2 +- scripts/lib/cn1ss.sh | 321 ++-- scripts/run-android-instrumentation-tests.sh | 4 +- scripts/run-ios-ui-tests.sh | 4 +- scripts/run-javascript-screenshot-tests.sh | 4 +- scripts/run-javase-device-tests.sh | 2 +- scripts/run-mac-catalyst-ui-tests.sh | 4 +- scripts/run-macos-ui-tests.sh | 4 +- scripts/run-tv-ui-tests.sh | 4 +- scripts/run-watch-ui-tests.sh | 4 +- scripts/website/build.sh | 3 +- scripts/windows/build-screenshot.bat | 2 +- scripts/windows/build-wsorch.bat | 2 +- scripts/windows/run-hello.bat | 18 +- vm/JavaAPI/src/java/lang/AssertionError.java | 48 +- .../cn1ss/com/demo/Cn1ssScreenshotServer.java | 308 ---- vm/backend/demo/cn1ss/com/demo/SelfCheck.java | 205 --- .../demo/petserver/com/demo/PetServer.java | 25 + .../demo/selftest/com/demo/SelfTest.java | 10 +- .../{ => impl}/backend/DevConsole.java | 2 +- .../{ => impl}/backend/DevConsole.java | 2 +- vm/backend/shared-sources.sh | 2 +- .../src/com/codename1/backend/Access.java | 291 ++++ .../src/com/codename1/backend/Backend.java | 287 ++-- .../com/codename1/backend/Compression.java | 158 ++ .../src/com/codename1/backend/Config.java | 49 +- .../src/com/codename1/backend/Cors.java | 183 +++ .../src/com/codename1/backend/DataSource.java | 2 + .../src/com/codename1/backend/HttpServer.java | 437 +++++- .../com/codename1/backend/HttpSession.java | 12 +- .../src/com/codename1/backend/Multipart.java | 321 ++++ .../src/com/codename1/backend/RequestLog.java | 5 +- .../com/codename1/backend/SessionStore.java | 2 +- .../src/com/codename1/backend/Sessions.java | 13 +- .../com/codename1/backend/TaskExecutor.java | 8 +- .../src/com/codename1/backend/Tasks.java | 20 +- .../src/com/codename1/backend/Tracing.java | 2 +- .../com/codename1/backend/Transactions.java | 36 +- .../backend/annotations/Component.java | 3 - .../backend/annotations/EnableManagement.java | 55 - .../backend/annotations/RequestParam.java | 6 +- ...DataSourceConfig.java => RequestPart.java} | 39 +- .../backend/annotations/SessionConfig.java | 63 - .../codename1/backend/metrics/Metrics.java | 65 +- .../codename1/backend/orm/EntityManager.java | 6 +- .../{ => impl}/backend/AsyncTask.java | 20 +- .../codename1/impl/backend/BackendAccess.java | 237 +++ .../impl/backend/BackendApplication.java | 77 + .../{ => impl}/backend/CronSchedule.java | 2 +- .../{ => impl}/backend/JsonCodec.java | 6 +- .../{ => impl}/backend/ManagedBean.java | 2 +- .../{ => impl}/backend/Management.java | 17 +- .../codename1/impl/backend/MetricsAccess.java | 84 + .../{ => impl}/backend/Scheduler.java | 25 +- .../backend}/TransactionSession.java | 6 +- .../codename1/{ => impl}/backend/Wiring.java | 4 +- .../impl/backend/WiringEnvironment.java | 123 ++ .../{ => impl}/backend/mcp/DevTools.java | 22 +- .../{ => impl}/backend/mcp/McpArgs.java | 2 +- .../{ => impl}/backend/mcp/McpServer.java | 2 +- .../{ => impl}/backend/mcp/McpTool.java | 2 +- .../{ => impl}/backend/mcp/package-info.java | 2 +- .../codename1/impl/orm/BackendSqlAccess.java | 9 +- .../backend/test/BackendTestExtension.java | 103 ++ .../codename1/impl/backend/test/Mocks.java | 64 + .../org/junit/jupiter/api/AfterAll.java | 40 + .../org/junit/jupiter/api/AfterEach.java | 40 + .../org/junit/jupiter/api/Assertions.java | 743 +++++++++ .../org/junit/jupiter/api/Assumptions.java | 74 + .../org/junit/jupiter/api/BeforeAll.java | 40 + .../org/junit/jupiter/api/BeforeEach.java | 40 + .../org/junit/jupiter/api/Disabled.java} | 19 +- .../org/junit/jupiter/api/DisplayName.java | 42 + .../junit-shim/org/junit/jupiter/api/Tag.java | 42 + .../org/junit/jupiter/api/Test.java} | 19 +- .../jupiter/api/extension/ExtendWith.java} | 33 +- .../jupiter/api/extension/Extension.java | 21 +- .../jupiter/api/function/Executable.java | 35 + .../api/function/ThrowingSupplier.java | 35 + .../org/opentest4j/AssertionFailedError.java | 75 + .../org/opentest4j/TestAbortedException.java | 41 + .../backend/test/BackendTestExtension.java} | 15 +- .../codename1/impl/backend/test/Mocks.java | 39 + .../codename1/backend/test/BackendTest.java | 80 + .../backend/test/ContentResultMatchers.java | 130 ++ .../backend/test/CookieResultMatchers.java | 71 + .../backend/test/HeaderResultMatchers.java | 90 ++ .../codename1/backend/test/HttpEntity.java | 54 + .../codename1/backend/test/HttpHeaders.java | 111 ++ .../codename1/backend/test/HttpMethod.java} | 15 +- .../codename1/backend/test/HttpStatus.java | 185 +++ .../backend/test/HttpStatusCode.java | 26 +- .../com/codename1/backend/test/JsonPath.java | 175 +++ .../backend/test/JsonPathResultMatchers.java | 149 ++ .../backend/test/LocalServerPort.java} | 12 +- .../com/codename1/backend/test/Matchers.java | 140 ++ .../com/codename1/backend/test/MediaType.java | 47 + .../backend/test/MockMultipartFile.java | 61 + .../test/MockMultipartRequestBuilder.java | 96 ++ .../com/codename1/backend/test/MockMvc.java | 57 + .../backend/test/MockMvcRequestBuilders.java | 76 + .../backend/test/MockMvcResultHandlers.java | 51 + .../backend/test/MockMvcResultMatchers.java | 88 ++ .../backend/test/MockRequestBuilder.java | 250 +++ .../codename1/backend/test/MockResponse.java | 113 ++ .../codename1/backend/test/MockitoBean.java} | 40 +- .../com/codename1/backend/test/MvcResult.java | 50 + .../backend/test/ResponseEntity.java | 47 + .../codename1/backend/test/ResultActions.java | 66 + .../codename1/backend/test/ResultHandler.java | 28 + .../codename1/backend/test/ResultMatcher.java | 29 + .../backend/test/StatusResultMatchers.java | 179 +++ .../backend/test/TestConfiguration.java} | 40 +- .../backend/test/TestRestTemplate.java | 198 +++ .../codename1/backend/test/package-info.java | 55 + .../impl/backend/test/TestBeans.java | 32 + .../impl/backend/test/TestContext.java | 67 + .../impl/backend/test/TestContexts.java | 119 ++ .../impl/backend/test/TestEnvironment.java | 78 + .../codename1/impl/backend/test/TestRun.java | 139 ++ .../impl/backend/test/package-info.java | 25 + .../BackendHttpIntegrationTest.java | 37 + .../tools/translator/BackendTestSupport.java | 28 + .../BackendWebSocketIntegrationTest.java | 34 +- .../CleanTargetIntegrationTest.java | 22 +- .../CleanTargetLinuxIntegrationTest.java | 19 +- .../tools/translator/Cn1ssTestServer.java | 91 ++ 277 files changed, 16305 insertions(+), 4102 deletions(-) rename docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/{beans/WiringTest.java => Uploads.java} (54%) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/FakeGreetingsTest.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApi.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/GreetingApiTest.java rename vm/backend/src/com/codename1/backend/annotations/RequestScope.java => docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/Greetings.java (76%) create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/MockedGreetingsTest.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/ServedGreetingTest.java create mode 100644 docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/TunedGreetingTest.java create mode 100644 docs/demos/common/src/main/snippets/developer-guide/backend-observability.properties create mode 100644 docs/demos/common/src/main/snippets/developer-guide/backend-testing.sh create mode 100644 docs/demos/common/src/main/snippets/developer-guide/backend-testing.xml create mode 100644 docs/demos/common/src/main/snippets/developer-guide/backend-web.properties create mode 100644 docs/developer-guide/Backend-Testing.asciidoc create mode 100644 maven/backend-test/pom.xml create mode 100644 maven/backend-test/spotbugs-exclude.xml create mode 100644 maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java rename maven/backend/src/test/java/com/codename1/backend/mcp/{DevToolsTest.java => StdioBridgeTest.java} (81%) rename maven/backend/src/test/java/com/codename1/{ => impl}/backend/JsonCodecTest.java (99%) create mode 100644 maven/backend/src/test/java/com/codename1/impl/backend/SchedulerLeaseTest.java create mode 100644 maven/backend/src/test/java/com/codename1/impl/backend/mcp/DevToolsTest.java rename maven/backend/src/test/java/com/codename1/{ => impl}/backend/mcp/McpDispatchTest.java (98%) create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ApiTest.java create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/backend/src/test/java/ServedApiTest.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractBackendNativeMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendTestMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompiledTestReport.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessTestAnnotationsMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTests.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java create mode 100755 scripts/check-backend-javadoc-internals.sh delete mode 100644 scripts/common/java/Cn1ssScreenshotServer.java create mode 100644 scripts/hellocodenameone/backend/application.properties create mode 100644 scripts/hellocodenameone/backend/pom.xml create mode 100755 scripts/hellocodenameone/backend/server.sh create mode 100644 scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpoint.java create mode 100644 scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/Pet.java create mode 100644 scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetApi.java create mode 100644 scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/PetStore.java create mode 100644 scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ProbeApi.java create mode 100644 scripts/hellocodenameone/backend/src/main/java/com/codenameone/examples/hellocodenameone/backend/ScreenshotStore.java create mode 100644 scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/Cn1ssEndpointTest.java create mode 100644 scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/FakeStoreTest.java create mode 100644 scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/MockedStoreTest.java create mode 100644 scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/PetApiTest.java create mode 100644 scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ProbeApiTest.java create mode 100644 scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ServedTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendClientTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendConnectionRequestTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendRestTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTransferTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTypedClientTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetDto.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/PetsApi.java delete mode 100644 vm/backend/demo/cn1ss/com/demo/Cn1ssScreenshotServer.java delete mode 100644 vm/backend/demo/cn1ss/com/demo/SelfCheck.java rename vm/backend/impl/javase/com/codename1/{ => impl}/backend/DevConsole.java (99%) rename vm/backend/impl/parparvm/com/codename1/{ => impl}/backend/DevConsole.java (97%) create mode 100644 vm/backend/src/com/codename1/backend/Access.java create mode 100644 vm/backend/src/com/codename1/backend/Compression.java create mode 100644 vm/backend/src/com/codename1/backend/Cors.java create mode 100644 vm/backend/src/com/codename1/backend/Multipart.java delete mode 100644 vm/backend/src/com/codename1/backend/annotations/EnableManagement.java rename vm/backend/src/com/codename1/backend/annotations/{DataSourceConfig.java => RequestPart.java} (60%) delete mode 100644 vm/backend/src/com/codename1/backend/annotations/SessionConfig.java rename vm/backend/src/com/codename1/{ => impl}/backend/AsyncTask.java (95%) create mode 100644 vm/backend/src/com/codename1/impl/backend/BackendAccess.java create mode 100644 vm/backend/src/com/codename1/impl/backend/BackendApplication.java rename vm/backend/src/com/codename1/{ => impl}/backend/CronSchedule.java (99%) rename vm/backend/src/com/codename1/{ => impl}/backend/JsonCodec.java (99%) rename vm/backend/src/com/codename1/{ => impl}/backend/ManagedBean.java (98%) rename vm/backend/src/com/codename1/{ => impl}/backend/Management.java (96%) create mode 100644 vm/backend/src/com/codename1/impl/backend/MetricsAccess.java rename vm/backend/src/com/codename1/{ => impl}/backend/Scheduler.java (96%) rename vm/backend/src/com/codename1/{backend/orm => impl/backend}/TransactionSession.java (97%) rename vm/backend/src/com/codename1/{ => impl}/backend/Wiring.java (99%) create mode 100644 vm/backend/src/com/codename1/impl/backend/WiringEnvironment.java rename vm/backend/src/com/codename1/{ => impl}/backend/mcp/DevTools.java (97%) rename vm/backend/src/com/codename1/{ => impl}/backend/mcp/McpArgs.java (99%) rename vm/backend/src/com/codename1/{ => impl}/backend/mcp/McpServer.java (99%) rename vm/backend/src/com/codename1/{ => impl}/backend/mcp/McpTool.java (98%) rename vm/backend/src/com/codename1/{ => impl}/backend/mcp/package-info.java (97%) create mode 100644 vm/backend/test/javase/com/codename1/impl/backend/test/BackendTestExtension.java create mode 100644 vm/backend/test/javase/com/codename1/impl/backend/test/Mocks.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/AfterAll.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/AfterEach.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/Assumptions.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeAll.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/BeforeEach.java rename vm/backend/{src/com/codename1/backend/annotations/Service.java => test/junit-shim/org/junit/jupiter/api/Disabled.java} (68%) create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/DisplayName.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/Tag.java rename vm/backend/{src/com/codename1/backend/annotations/Repository.java => test/junit-shim/org/junit/jupiter/api/Test.java} (70%) rename vm/backend/{src/com/codename1/backend/annotations/ServerConfig.java => test/junit-shim/org/junit/jupiter/api/extension/ExtendWith.java} (62%) rename docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/AppSettings.java => vm/backend/test/junit-shim/org/junit/jupiter/api/extension/Extension.java (69%) create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/function/Executable.java create mode 100644 vm/backend/test/junit-shim/org/junit/jupiter/api/function/ThrowingSupplier.java create mode 100644 vm/backend/test/junit-shim/org/opentest4j/AssertionFailedError.java create mode 100644 vm/backend/test/junit-shim/org/opentest4j/TestAbortedException.java rename vm/backend/{src/com/codename1/backend/annotations/EnableAsync.java => test/parparvm/com/codename1/impl/backend/test/BackendTestExtension.java} (75%) create mode 100644 vm/backend/test/parparvm/com/codename1/impl/backend/test/Mocks.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/BackendTest.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ContentResultMatchers.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/CookieResultMatchers.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/HeaderResultMatchers.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/HttpEntity.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/HttpHeaders.java rename vm/backend/{src/com/codename1/backend/annotations/EnableScheduling.java => test/src/com/codename1/backend/test/HttpMethod.java} (75%) create mode 100644 vm/backend/test/src/com/codename1/backend/test/HttpStatus.java rename docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/beans/OpsSettings.java => vm/backend/test/src/com/codename1/backend/test/HttpStatusCode.java (74%) create mode 100644 vm/backend/test/src/com/codename1/backend/test/JsonPath.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/JsonPathResultMatchers.java rename vm/backend/{src/com/codename1/backend/annotations/SessionScope.java => test/src/com/codename1/backend/test/LocalServerPort.java} (83%) create mode 100644 vm/backend/test/src/com/codename1/backend/test/Matchers.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MediaType.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockMultipartFile.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockMultipartRequestBuilder.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockMvc.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockMvcRequestBuilders.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockMvcResultHandlers.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockMvcResultMatchers.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockRequestBuilder.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/MockResponse.java rename vm/backend/{src/com/codename1/backend/annotations/StaticFilesConfig.java => test/src/com/codename1/backend/test/MockitoBean.java} (62%) create mode 100644 vm/backend/test/src/com/codename1/backend/test/MvcResult.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ResponseEntity.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ResultActions.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ResultHandler.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/ResultMatcher.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/StatusResultMatchers.java rename vm/backend/{src/com/codename1/backend/annotations/EnableMcpServer.java => test/src/com/codename1/backend/test/TestConfiguration.java} (59%) create mode 100644 vm/backend/test/src/com/codename1/backend/test/TestRestTemplate.java create mode 100644 vm/backend/test/src/com/codename1/backend/test/package-info.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/TestBeans.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/TestContext.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/TestContexts.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/TestRun.java create mode 100644 vm/backend/test/src/com/codename1/impl/backend/test/package-info.java create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/Cn1ssTestServer.java diff --git a/.github/ci/apple-checks.json b/.github/ci/apple-checks.json index 51351a973cf..b0e04f79157 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" ] }, @@ -230,7 +230,7 @@ "!maven/cn1-unity-levelplay/**", "!maven/core-unittests/**", "!docs/**", - "vm/backend/demo/cn1ss/**" + "scripts/hellocodenameone/backend/**" ], "push": [ ".github/workflows/scripts-mac-catalyst.yml", @@ -258,7 +258,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 15bede3c894..df78130e294 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/parparvm-tests-windows.yml b/.github/workflows/parparvm-tests-windows.yml index 0bd6e65bc04..97fdc7059a3 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. @@ -654,6 +679,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: @@ -692,6 +723,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 ba9944b52ff..706356812bc 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -20,6 +20,9 @@ on: # job. The translated tests in pr.yml catch a core break first. - 'maven/codenameone-maven-plugin/**' - '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 @@ -33,6 +36,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/**' @@ -56,6 +62,9 @@ on: # job. The translated tests in pr.yml catch a core break first. - 'maven/codenameone-maven-plugin/**' - '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 @@ -69,6 +78,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/**' @@ -310,7 +322,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 \ @@ -363,6 +375,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 @@ -481,6 +497,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 e44a0476f98..ea33b54ab88 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,codenameone-maven-plugin,backend,backend-parparvm -am verify + -pl android,ios,codenameone-maven-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-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-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 }} @@ -621,7 +621,8 @@ jobs: "ios:maven/ios/target" \ "bytecode-translator:vm/ByteCodeTranslator/target" \ "maven-plugin:maven/codenameone-maven-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 @@ -640,6 +641,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 @@ -741,16 +748,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-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-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-hint-catalog:build-hint-tools:backend + QUALITY_REPORT_REQUIRED_SPOTBUGS: core-unittests:android:ios:ByteCodeTranslator:codenameone-maven-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 307d1a58021..cb8a491a28e 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 @@ -936,7 +936,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. @@ -1094,7 +1094,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 efa6a5794c9..19acb53f693 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 eef2e2829d2..dce577f20cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,8 +323,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/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index c25ad12abea..78bf2cbd3df 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -5738,7 +5738,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 +5824,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 +5867,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/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/RequestScope.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/RequestScope.java rename to docs/demos/backend/src/main/java/com/codenameone/developerguide/backend/testing/Greetings.java index fab81182389..c484fe6e93a 100644 --- a/vm/backend/src/com/codename1/backend/annotations/RequestScope.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; -/// Shorthand for `@Scope("request")`. -@Retention(RetentionPolicy.CLASS) -@Target({ElementType.TYPE, ElementType.METHOD}) -public @interface RequestScope { +/** 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..ddb41231023 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-testing.sh @@ -0,0 +1,9 @@ +// 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[] 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/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 f2f2efd0b52..d4c24e29fed 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..b97f05805c8 --- /dev/null +++ b/docs/developer-guide/Backend-Testing.asciidoc @@ -0,0 +1,285 @@ +[[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] +---- + +=== 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 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 that imports 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. +* **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. +* **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..3d2abccde9f 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,93 @@ 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. 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. + Cross-origin requests are refused while this is unset. + +| `cn1.cors.allowedMethods` +| The methods a preflight allows. 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, with a 403 for an origin +not on the list, and adds the headers to every response to an allowed origin. 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 +371,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 ff28fc319f3..0760502a3d4 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -317,6 +317,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 <>. @@ -368,55 +372,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/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index c06c3097ef5..fa3745b2698 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -123,6 +123,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/img/backend-build-pipeline.svg b/docs/developer-guide/img/backend-build-pipeline.svg index 96e48244585..ab3a307fb35 100644 --- a/docs/developer-guide/img/backend-build-pipeline.svg +++ b/docs/developer-guide/img/backend-build-pipeline.svg @@ -9,7 +9,7 @@ @RestController controllers - @Service @Component + @Component beans @Configuration + @Bean 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/ConfigTest.java b/maven/backend/src/test/java/com/codename1/backend/ConfigTest.java index de7670bf2a6..429be6271b1 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,26 @@ void systemPropertyWins(@TempDir File dir) throws Exception { assertEquals(9999, Config.load(dir.getAbsolutePath()).getInt(Config.SERVER_PORT, 0)); } + @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("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..ad412412f9e --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java @@ -0,0 +1,318 @@ +/* + * 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.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(); + } + } + + /// 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("/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); + + 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 200"), other); + assertTrue(!other.contains("Access-Control-Allow-Origin"), + "an origin the policy does not list was allowed to read: " + other); + } + + @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"); + + 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"); + } + + 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/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 9fb192cc5a0..a56a3ebd71d 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 @@ -281,6 +281,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 dc94b22dc95..1ff20e53ad5 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} - - - @@ -172,6 +155,43 @@ + + + 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-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 67b3f91dc6c..d0d8145d10f 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -67,6 +67,14 @@ ${project.version} test + + + com.codenameone + codenameone-backend-test + ${project.version} + test + diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractBackendNativeMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractBackendNativeMojo.java new file mode 100644 index 00000000000..7e991daf4f2 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractBackendNativeMojo.java @@ -0,0 +1,1392 @@ +/* + * 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.artifact.Artifact; +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.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.model.Plugin; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.apache.maven.repository.RepositorySystem; +import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; +import org.apache.maven.artifact.resolver.ArtifactResolutionResult; +import org.apache.maven.artifact.repository.ArtifactRepository; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +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 com.codename1.maven.processors.OrmAnnotationProcessor; +import com.codename1.maven.processors.RestControllerAnnotationProcessor; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * What {@link BackendPackageMojo} and {@link BackendTestMojo} share: compiling a + * backend module against the ParparVM JavaAPI, generating its wiring into that + * tree, translating it and linking a native binary. See BackendPackageMojo for + * the steps; the test goal adds its tests at the hooks below. + * + * Carries no goal annotation of its own, so neither goal inherits the other's: the + * package goal forks the lifecycle to compile, and the test goal, bound into the + * test phase, must not fork it again. + */ +public abstract class AbstractBackendNativeMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true, required = true) + MavenProject project; + + @Parameter(defaultValue = "${localRepository}", readonly = true, required = true) + private ArtifactRepository localRepository; + + @Component + private RepositorySystem repositorySystem; + + /** + * The class whose main() becomes the program's entry point. + * + * Optional. A module whose server is written as `@RestController` classes has no + * main of its own -- one is generated from them -- and naming a class that does + * not exist is worse than leaving this out. + */ + @Parameter(property = "cn1.backend.mainClass") + String mainClass; + + /** + * Where the binary goes. Defaults to target/<artifactId>. + */ + @Parameter(property = "cn1.backend.output") + private File output; + + /** + * A Linux deployment target -- musl-x86_64, musl-arm64, glibc-x86_64, + * glibc-arm64 -- built in a container. Omitted, it compiles for this machine + * with the host compiler, which is what a developer wants and what CI checks. + */ + @Parameter(property = "cn1.backend.target") + private String target; + + /** + * The JDK whose javac compiles for translation and whose java runs the + * translator. Defaults to the JDK running Maven. + * + * Any JDK 8 or newer works; see {@link #resolveJdk()} for why this is no + * longer pinned to a JDK 8. + */ + @Parameter(property = "cn1.backend.jdk") + private String jdkHome; + + /** + * The JDK 8 this goal used to require. + * + * Kept, and still honoured, because projects and CI jobs are setting it. It is + * no longer a requirement: with neither this nor cn1.backend.jdk set, the JDK + * running Maven is what compiles and translates. + */ + @Parameter(property = "cn1.backend.jdk8", defaultValue = "${env.JDK_8_HOME}") + private String jdk8Home; + + /** Extra flags for the C compiler. */ + @Parameter(property = "cn1.backend.cflags") + private String cflags; + + /** + * Whether to link the bundled SQLite engine. Off saves about 2MB in a service + * that talks to PostgreSQL or MySQL instead, which speak their wire protocols + * with no engine linked at all. + */ + @Parameter(property = "cn1.backend.sqlite", defaultValue = "true") + private boolean sqlite; + + /** + * Whether a failed cast throws. + * + * On by default here and off for app targets, which is the one place the + * server build departs from the mobile one deliberately: on a phone a bad cast + * costs one user a crash, and on a server the object with the wrong type + * arrived from the network, so reading its fields as another type kills every + * connection the process was serving. + */ + @Parameter(property = "cn1.backend.checkedCasts", defaultValue = "true") + private boolean checkedCasts; + + /** + * Whether the packaged server carries the development MCP tools. Off by + * default: they read the database and call the server on an agent's behalf, + * and a production binary should not contain them at all -- not merely have + * them switched off. {@code cn1:backend} runs the JVM build, which has them. + */ + @Parameter(property = "cn1.backend.devTools", defaultValue = "false") + private boolean devTools; + + public void execute() throws MojoExecutionException, MojoFailureException { + File jdk = resolveJdk(); + File work = new File(project.getBuild().getDirectory(), "cn1-backend"); + File classes = new File(work, "classes"); + File javaApi = new File(work, "javaapi-classes"); + File runtimeSources = new File(work, "runtime-src"); + File nativeSources = new File(work, "native"); + File translated = new File(work, "translated"); + File dependencyClasses = new File(work, "dependency-classes"); + // Emptied, not just created. Every one of these is derived, and nothing here + // removes a file that stopped being produced: a renamed or deleted source + // left its old .class behind, the translator still read it, and even + // requireMainClass accepted a main class the module no longer had -- so the + // package that came out was the previous implementation. Rebuilding from + // clean costs nothing, since neither the javac nor the clang pass below was + // ever incremental. + emptyDirs(classes, javaApi, runtimeSources, nativeSources, translated, + dependencyClasses); + mkdirs(work, classes, javaApi, runtimeSources, nativeSources, translated, + dependencyClasses); + + // The version of the runtime THIS MODULE compiles against, not the + // module's own: the sources handed to the translator have to be the same + // ones behind the classes the developer just built against, or the local + // run and the deployed binary are different programs. + String runtimeVersion = backendRuntimeVersion(); + File runtimeJar = resolve("com.codenameone", "codenameone-backend", + runtimeVersion, "parparvm-sources"); + unzip(runtimeJar, runtimeSources, nativeSources); + File parparvmBundle = resolve("com.codenameone", "codenameone-parparvm", + runtimeVersion, "bundle"); + File bundleDir = new File(work, "parparvm"); + mkdirs(bundleDir); + unzip(parparvmBundle, bundleDir, null); + File compilerJar = new File(bundleDir, "parparvm-compiler.jar"); + File javaApiJar = new File(bundleDir, "parparvm-java-api.jar"); + if (!compilerJar.isFile() || !javaApiJar.isFile()) { + throw new MojoExecutionException("The ParparVM bundle is missing its " + + "compiler or JavaAPI jar: " + parparvmBundle); + } + 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 = binaryFile(); + link(translated, binary); + afterLink(binary); + } + + /** The source trees compiled beside the module's and the runtime's. */ + private final List extraSources = new ArrayList(); + + /// Adds a source tree to the compile, for a goal built on this one. + void addSources(File dir) { + extraSources.add(dir); + } + + /// Before the compile: a goal built on this one unpacks more sources here. + void prepareSources(File work, String runtimeVersion) throws MojoExecutionException, + MojoFailureException { + } + + /// After the routers and wiring are generated into `classes`. + void afterGenerate(File jdk, File javaApi, File classes, File work) + throws MojoExecutionException, MojoFailureException { + } + + /// Where the binary goes. + File binaryFile() { + return output != null ? output + : new File(project.getBuild().getDirectory(), project.getArtifactId()); + } + + /// After the binary is linked. + void afterLink(File binary) throws MojoExecutionException, MojoFailureException { + 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. + */ + /** + * Generates the routers and the bootstrap for this module's `@RestController` + * classes, into the directory this goal has just compiled into. + * + * Not left to the `process-annotations` goal, which writes into Maven's + * target/classes: that is a different build, made against a JDK rather than + * against the backend's class library, and the translator never reads it. A + * router generated there would be absent from the binary while looking present + * in the project. Generating into the tree that is about to be translated is + * what makes the wiring real. + * + * Sets mainClass to the generated bootstrap when the module did not name one. + */ + private void generateControllers(File classes, File work) throws MojoExecutionException { + Map index; + try { + index = ClassScanner.scan(classes); + } catch (ProcessingException err) { + throw new MojoExecutionException("Could not scan the compiled backend classes: " + + err.getMessage(), err); + } + RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); + processor.setDevTools(devTools); + ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index, + getLog(), project.getBasedir(), new Properties(), mainClass, + java.util.Collections.emptyList(), "UTF-8", + compileClasspathWithoutRuntime()); + // THE ENTITIES FIRST, into this same tree. + // + // The entry point generated below references cn1app.BackendDaoBootstrap + // whenever the module has an @Entity, because that reference is the only + // thing that keeps the generated daos in the binary -- the translator + // drops a class nothing names. This goal empties and rebuilds its own + // class tree and never copies Maven's target/classes into it, so a dao + // generated by the process-annotations goal is not here: without this + // pass the entry point names a class that does not exist and packaging + // fails at its own javac, on any project that has both an entity and a + // controller. + // + // The flavour is forced because the classpath cannot answer for it here; + // see OrmAnnotationProcessor#setBackendFlavour. + OrmAnnotationProcessor entities = new OrmAnnotationProcessor(); + entities.setBackendFlavour(true); + try { + entities.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + entities.processClass(cls, ctx); + } + } + entities.finish(ctx); + } catch (ProcessingException err) { + throw new MojoExecutionException("Could not process @Entity: " + + err.getMessage(), err); + } + // Re-scanned, so the controller pass sees the daos and the bootstrap the + // pass above just wrote. hasGeneratedDaos() reads this index. + try { + index = ClassScanner.scan(classes); + } catch (ProcessingException err) { + throw new MojoExecutionException("Could not re-scan the backend classes after " + + "generating the entity daos: " + err.getMessage(), err); + } + try { + processor.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) { + processor.processClass(cls, ctx); + } + } + // finish() runs the bean pass -- the wiring, the rewritten classes and + // the classes generated beside them -- into this same tree. + processor.finish(ctx); + entities.enhance(ctx); + } catch (ProcessingException err) { + throw new MojoExecutionException("Could not process @RestController: " + + err.getMessage(), err); + } + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("The backend's annotations could not be processed:"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) { + sb.append("\n ").append(e); + } + throw new MojoExecutionException(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 MojoExecutionException("Could not write " + file + ": " + + err.getMessage(), err); + } + } + byte[] generated = ctx.getEmittedResources() + .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE); + if (generated == null) { + return; + } + String name; + try { + name = new String(generated, "UTF-8").trim(); + } catch (java.io.UnsupportedEncodingException err) { + throw new MojoExecutionException("UTF-8 is required of every JDK", err); + } + if (mainClass == null || mainClass.length() == 0) { + mainClass = name; + getLog().info("cn1: entry point " + name + ", generated from @RestController"); + } + } + + /** + * Fails here, with the reason, rather than inside the translator. + * + * The compile below reads .java and only .java, on purpose: recompiling against + * the JavaAPI bootclasspath is what turns "this backend uses a class the runtime + * does not have" into a compile error instead of a link failure on the device, + * and reusing the jar Maven already built would give that up. The cost is that a + * main class written in Kotlin -- which `cn1:backend` runs happily, because that + * goal is a JVM launch -- never reaches this directory, and the translator's own + * complaint about it names neither Kotlin nor the reason. So say it plainly. The + * developer guide's "Limits worth knowing" carries the same statement. + */ + /** + * The encoding this module's sources are actually written in. + * + *

These are the SAME sources the lifecycle has already compiled, so + * reading them differently here is a second, disagreeing compilation of one + * tree: a module that declares another encoding either fails packaging on + * bytes javac accepted a phase earlier, or -- worse, because nothing says so + * -- translates string literals and identifiers that are not the ones the JVM + * build produced. + * + *

Resolved the way the compiler plugin resolves it, most specific first: + * an explicit <encoding> on maven-compiler-plugin, then + * project.build.sourceEncoding, and UTF-8 only when the module says nothing. + * The platform default is deliberately not the last resort -- it makes the + * build depend on the machine that runs it, which is the reason Maven warns + * about it. + */ + String sourceEncoding() { + 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"); + if (encoding != null && encoding.getValue() != null) { + String declared = encoding.getValue().trim(); + // A configuration that is still a property reference is one Maven + // would have interpolated; an uninterpolated ${...} names no + // charset, so fall through rather than hand javac a literal. + if (declared.length() > 0 && declared.indexOf("${") < 0) { + return declared; + } + } + } + String property = project.getProperties() == null ? null + : project.getProperties().getProperty("project.build.sourceEncoding"); + if (property != null && property.trim().length() > 0) { + return property.trim(); + } + return "UTF-8"; + } + + private void requireMainClass(File classes) throws MojoFailureException { + if (mainClass == null || mainClass.length() == 0) { + throw new MojoFailureException("No entry point: set , or annotate " + + "a class with @RestController and let the bootstrap be generated " + + "from it"); + } + if (new File(classes, mainClass.replace('.', '/') + ".class").isFile()) { + return; + } + throw new MojoFailureException("The main class " + mainClass + " was not " + + "produced by the backend compile. This goal compiles Java sources " + + "against the backend class library, so a main class written in " + + "Kotlin or generated into the build output is not visible to it " + + "yet -- write the entry point in Java, or keep it on the JVM with " + + "cn1:backend"); + } + + private void compile(File jdk, File javaApi, File runtimeSources, File classes) + throws MojoExecutionException, MojoFailureException { + List sources = new ArrayList(); + for (Object root : project.getCompileSourceRoots()) { + collectJava(new File(String.valueOf(root)), sources); + } + collectJava(runtimeSources, sources); + for (File extra : extraSources) { + collectJava(extra, sources); + } + if (sources.isEmpty()) { + throw new MojoFailureException("No Java sources to compile"); + } + + List command = new ArrayList(Arrays.asList( + new File(jdk, "bin/javac").getAbsolutePath(), + "-nowarn", "-encoding", sourceEncoding(), + "-bootclasspath", javaApi.getAbsolutePath(), + "-source", "1.8", "-target", "1.8", + "-d", classes.getAbsolutePath())); + // The module's own dependencies, MINUS the backend runtime: its compiled + // form was built against a JDK, and the sources unpacked above are the + // half that belongs on this bootclasspath. + List classpath = new ArrayList(); + for (Object element : compileClasspathWithoutRuntime()) { + classpath.add(String.valueOf(element)); + } + if (!classpath.isEmpty()) { + command.add("-classpath"); + command.add(join(classpath, File.pathSeparator)); + } + command.addAll(sources); + // -source/-target 8 is the translator's input format and is not a property + // of the compiler running here: every javac from 8 up emits the same class + // file version 52 for it. A javac that has dropped the option says so in + // its own words and names no remedy, so the remedy is added to it. + // + // THE REMEDY IS THE JDK RUNNING MAVEN, not -Dcn1.backend.jdk, even though + // that property is what selects the compiler on this line. Only the two + // FORKED steps read it -- this javac and the translator's java. + // generateControllers() compiles the router and the entry point in + // process, through ToolProvider.getSystemJavaCompiler(), which is the + // Maven JVM's compiler and cannot be pointed anywhere; it asks for + // -source 1.8 as well, so it fails next on a compiler that has dropped + // it. Naming the property here would send a developer to a setting that + // moves the failure by one step and no further. + // + // Routing that in-process compile through the selected JDK is not the + // answer either. A release that removes -source 8 takes it away from the + // Maven JVM too, and this build needs a javac that emits class file + // version 52 in many more places than this goal -- codenameone-core + // compiles at 1.5. The whole toolchain moves then, not one mojo. + try { + run(command, project.getBasedir(), "compile the backend sources"); + } catch (MojoFailureException err) { + throw dropsSourceEight(err) + ? new MojoFailureException(err.getMessage() + "\n\n" + + SOURCE_EIGHT_REMOVED_HINT, err) + : err; + } + stageResources(classes); + } + + /** + * Copies the module's resources in beside the classes just compiled. + * + * This directory is emptied and then filled from .java alone, and the project's + * own output directory is excluded from the translator input on purpose -- its + * classes were built against a JDK. The consequence was that anything read from + * the classpath, a properties or configuration file, was present under + * cn1:backend and simply absent from the packaged binary. Nothing failed at + * build time; the resource was just not there at runtime. + * + * Everything EXCEPT .class is taken, which is exactly the resources and none of + * the JDK-compiled code. + * + * ONLY Maven's processed output, never the raw resource directories. Those + * directories are what a / selects FROM, so copying them + * wholesale packaged the files the build was configured to leave out -- an + * environment file or a secret excluded on purpose would have gone into the + * executable, and the later overlay could not remove it. The processed copy is + * the answer Maven already computed. + */ + private void stageResources(File classes) throws MojoExecutionException { + File processed = new File(project.getBuild().getOutputDirectory()); + if (!processed.isDirectory()) { + // Nothing has processed the resources, so there are none to stage and + // nothing to guess at. Said out loud, because a resource silently absent + // from the binary is the failure this whole step exists to prevent. + if (!project.getBuild().getResources().isEmpty()) { + getLog().warn("cn1: this module declares resources but " + + processed + " does not exist, so none are packaged. Run " + + "process-resources first, or invoke this through the " + + "lifecycle rather than as a bare goal."); + } + return; + } + try { + int staged = copyNonClasses(processed, classes); + // Staged is not the same as READABLE, and the difference is silent. + // These files reach the translator, so anything that reads them at + // BUILD time works -- but the backend translates as app type "clean", + // and only the linux and windows types embed classpath resources into + // the binary. The clean runtime's Class.getResourceAsStream returns + // null unconditionally, so getResourceAsStream finds the file under + // cn1:backend, on the JVM, and finds nothing in the packaged + // executable. Said out loud rather than left to be discovered in + // production; embedding them is a change to the translator and the + // shared runtime, not to this goal. + if (staged > 0) { + getLog().warn("cn1: staged " + staged + " resource file(s) for translation, " + + "but a packaged backend cannot READ them: getResourceAsStream " + + "answers null in the translated runtime, though it works under " + + "cn1:backend. Read configuration from a file path or the " + + "environment instead of the classpath."); + } + } catch (IOException err) { + // A resource that cannot be staged is a packaging failure, not a note: + // the executable would be reported as built while missing something + // cn1:backend has, and the difference would first appear in production. + throw new MojoExecutionException("Could not stage the processed resources " + + "from " + processed + " into " + classes, err); + } + } + + /** @return how many non-class files were copied. */ + private int copyNonClasses(File from, File to) throws IOException { + if (from == null || !from.isDirectory()) { + return 0; + } + File[] children = from.listFiles(); + if (children == null) { + return 0; + } + int copied = 0; + for (File child : children) { + File target = new File(to, child.getName()); + if (child.isDirectory()) { + target.mkdirs(); + copied += copyNonClasses(child, target); + } else if (!child.getName().endsWith(".class")) { + copyFile(child, target); + copied++; + } + } + return copied; + } + + private static void copyFile(File from, File to) throws IOException { + InputStream in = new java.io.FileInputStream(from); + try { + OutputStream out = new java.io.FileOutputStream(to); + try { + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + out.write(chunk, 0, n); + } + } finally { + out.close(); + } + } finally { + in.close(); + } + } + + List compileClasspathWithoutRuntime() throws MojoExecutionException { + List classpath = new ArrayList(); + try { + for (Object element : project.getCompileClasspathElements()) { + classpath.add(String.valueOf(element)); + } + } catch (Exception err) { + throw new MojoExecutionException("Could not resolve the compile classpath", err); + } + return withoutRuntime(classpath, runtimeArtifactFile(), + project.getBuild().getOutputDirectory()); + } + + /** + * The classpath without the backend runtime and without this module's own + * output. + * + *

IDENTIFIED BY FILE, not by looking for "codenameone-backend" anywhere in + * a path. A project checked out under a directory whose name contains that -- + * /work/codenameone-backend-demo/ is the obvious one -- has EVERY reactor + * dependency below it match, so a backend depending on a sibling contract + * module lost it from both the compile classpath and the translation, and + * failed on classes it plainly depends on. The name of a directory somewhere + * above the project is not something a build should read meaning into. + * + *

The runtime is left out because its sources are compiled into `classes` + * already; the module's own output for the same reason. + * + * @param runtime the resolved runtime artifact, or null when it cannot be + * located -- then nothing is dropped for it, which is + * duplicate work rather than a missing class + */ + static List withoutRuntime(List classpath, File runtime, String ownOutput) { + List out = new ArrayList(); + File runtimeFile = runtime == null ? null : runtime.getAbsoluteFile(); + File output = ownOutput == null ? null : new File(ownOutput).getAbsoluteFile(); + for (int i = 0; i < classpath.size(); i++) { + String path = classpath.get(i); + File element = new File(path).getAbsoluteFile(); + if (runtimeFile != null && runtimeFile.equals(element)) { + continue; + } + if (output != null && output.equals(element)) { + continue; + } + out.add(path); + } + return out; + } + + /** The resolved file of com.codenameone:codenameone-backend, or null. */ + private File runtimeArtifactFile() { + java.util.Set artifacts = project.getArtifacts(); + if (artifacts != null) { + for (Artifact artifact : artifacts) { + if ("com.codenameone".equals(artifact.getGroupId()) + && "codenameone-backend".equals(artifact.getArtifactId())) { + return artifact.getFile(); + } + } + } + return null; + } + + /** + * The compile classpath staged as ONE directory the translator can read. + * + * ByteCodeTranslator walks its inputs with File.listFiles, which answers NULL + * for a jar -- and the walk reads null as an empty directory, so a dependency + * resolved from the repository as a jar contributed nothing at all, without a + * word. The build then failed much later, while linking, on the symbols of + * classes the translator had never been shown. A module in the same reactor + * resolves to its target/classes and worked, which is why the generated + * project's own contract module never showed this. + * + *

ONE TREE, FIRST WINS, IN CLASSPATH ORDER, and not a list of inputs. + * Parser.classIndex keeps the first definition of a class it parsed, so the + * order of the translator's inputs IS precedence -- and javac resolved the + * same classpath the same way, so anything that reorders it compiles against + * one definition and translates another. Staging settles it on disk instead: + * whatever arrives first is what is there, every later copy is skipped, and + * there is no order left to get wrong. It also means a class two dependencies + * both carry is PARSED once rather than twice, which is where duplicate + * symbols came from. + * + *

Directories are copied rather than passed through for that reason alone. + * They cost a copy of their class files per build, which is a reactor + * module's output and small beside the translation that follows. + * + *

cn1-native goes where the runtime jar's natives go, so a dependency that + * ships them is built rather than dropped just as quietly. META-INF is + * skipped out of jars by the same unpacking the runtime gets; a directory is + * copied as it stands, which is what passing it as an input already did. + * + * @param classpath compile classpath elements, in classpath order + * @param staged directory to stage into; assumed empty + * @param nativeSources where a dependency's cn1-native entries belong + */ + private List stageDependencyClasses(List classpath, File staged, + File nativeSources) throws MojoExecutionException { + List out = new ArrayList(); + boolean any = false; + for (int i = 0; i < classpath.size(); i++) { + File element = new File(classpath.get(i)); + if (element.isDirectory()) { + copyDirectoryFirstWins(element, staged, nativeSources); + any = true; + } else if (element.isFile()) { + unzip(element, staged, nativeSources, true); + any = true; + } + // An entry that is neither is one javac will complain about; there is + // nothing here to stage and nothing to say that it will not say. + } + if (any) { + out.add(staged.getAbsolutePath()); + } + return out; + } + + private void translate(File jdk, File compilerJar, File javaApi, File classes, + File nativeSources, File translated, File dependencyClasses) + throws MojoExecutionException, MojoFailureException { + String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); + String packageName = mainClass.lastIndexOf('.') < 0 ? "" + : mainClass.substring(0, mainClass.lastIndexOf('.')); + + // BEFORE the natives are copied below, because a dependency that ships + // cn1-native adds to them. + List dependencyInputs = stageDependencyClasses( + compileClasspathWithoutRuntime(), dependencyClasses, nativeSources); + + // The C has to be in the source root BEFORE the translator runs: it reads + // the directory to decide which native-only Java methods to keep, and the + // signature verifier checks every declared native against an actual + // implementation. + File sourceDir = new File(translated, "dist/" + simpleName + "-src"); + mkdirs(sourceDir); + copyDirectory(nativeSources, sourceDir); + + List command = new ArrayList(); + command.add(new File(jdk, "bin/java").getAbsolutePath()); + if (sqlite) { + command.add("-Dcn1.sqlite=true"); + } + if (checkedCasts) { + command.add("-Dcn1.checkedCasts=true"); + } + command.add("-cp"); + command.add(compilerJar.getAbsolutePath()); + command.add("com.codename1.tools.translator.ByteCodeTranslator"); + command.add("clean"); + // The module's dependencies belong on the translator's input, not only on + // javac's classpath. Without them a backend that uses a type from another + // module -- the shared contract or DTO module the generated project + // recommends -- compiles here and then fails to translate, because javac + // resolved the type from a jar whose bytecode the translator never sees. + // The runtime is excluded for the same reason it is excluded from javac's + // classpath: its sources are compiled into `classes` already. + StringBuilder translatorInput = new StringBuilder(); + translatorInput.append(javaApi.getAbsolutePath()) + .append(';').append(classes.getAbsolutePath()); + for (int i = 0; i < dependencyInputs.size(); i++) { + translatorInput.append(';').append(dependencyInputs.get(i)); + } + command.add(translatorInput.toString()); + command.add(translated.getAbsolutePath()); + command.add(simpleName); + command.add(packageName); + command.add(simpleName); + command.add("1.0"); + command.add("clean"); + command.add("none"); + run(command, project.getBasedir(), "translate the backend to C"); + } + + private void link(File translated, File binary) + throws MojoExecutionException, MojoFailureException { + String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); + File sourceDir = new File(translated, "dist/" + simpleName + "-src"); + // Kept as a loud failure rather than dropped: the parameter names a real + // capability, and silently ignoring -Dcn1.backend.target would hand back a + // host binary labelled as a cross-compiled one. The script named here lives in + // the Codename One repository, not in a generated project, which is why the + // message says where it is instead of assuming it is on hand. + if (target != null && target.length() > 0) { + throw new MojoFailureException("cn1.backend.target is not supported from " + + "this goal yet: it builds for the machine it runs on. The " + + "cross-compiled targets (musl-x86_64, musl-arm64, glibc-x86_64, " + + "glibc-arm64) are produced by package.sh in the Codename One " + + "repository, which drives one container image per target; run " + + "this goal inside a container of the target flavour to get the " + + "same artifact here"); + } + List command = new ArrayList(Arrays.asList( + "clang", "-O3", "-w", + // Mandatory for generated C: Java arithmetic wraps, and clang -O3 + // provably miscompiles the output without these. + "-fwrapv", "-fno-strict-aliasing", + "-fno-builtin-fmod", "-fno-builtin-fmodf")); + if (!sqlite) { + // Turning the engine OFF is two changes, not one. Without + // -Dcn1.sqlite=true the translator leaves cn1_sqlite3.h out, but + // cn1_backend_db.c is copied and compiled either way -- and its + // SQLite branch includes that header unconditionally, so the compile + // fails with "cn1_sqlite3.h file not found" and the option advertised + // as saving the engine could not produce a binary at all. The macro + // is what compiles that file to stubs instead, which answer "could + // not open" and become an IOException, rather than dropping the Db + // natives and taking their Java methods with them. build.sh has + // always set both; this half had only the first. + command.add("-DCN1_BACKEND_NO_SQLITE"); + } + if (cflags != null && cflags.trim().length() > 0) { + command.addAll(Arrays.asList(cflags.trim().split("\\s+"))); + } + // Before -I on the generated sources, which is where build.sh puts them. + command.addAll(hostLibraryFlags(opensslPrefixes(), nghttp2Prefixes())); + command.add("-I" + sourceDir.getAbsolutePath()); + File[] cFiles = sourceDir.listFiles(); + if (cFiles == null) { + throw new MojoExecutionException("The translator produced nothing in " + sourceDir); + } + for (File file : cFiles) { + String name = file.getName(); + // .S as well as .c, which is what vm/backend/build.sh compiles. The + // translator always emits cn1_virtual_thread_asm.S, and + // cn1_virtual_thread.c calls cn1VirtualThreadSwitch out of it, so a + // command that passed only .c reached the linker with that symbol + // undefined and this goal could not produce a binary at all. + // Generated resource assembly is in the same position. + if (name.endsWith(".c") || name.endsWith(".S") || name.endsWith(".s")) { + command.add(file.getAbsolutePath()); + } + } + command.addAll(Arrays.asList("-lm", "-lpthread", + "-lcurl", "-lssl", "-lcrypto", "-lnghttp2")); + command.add("-o"); + command.add(binary.getAbsolutePath()); + run(command, project.getBasedir(), "compile the generated C"); + } + + /** + * -I and -L for the TLS and HTTP/2 libraries, where this machine keeps them. + * + * macOS ships libcrypto WITHOUT its headers, so a stock machine with the + * usual Homebrew OpenSSL could not compile the generated C at all: the goal + * the documentation tells a developer to run failed on openssl/ssl.h, and the + * only way out was to work out cn1.backend.cflags for themselves. The same + * prefixes and the same probe headers as vm/backend/build.sh, so the two ways + * of building a backend look in the same places -- including OPENSSL_PREFIX + * and NGHTTP2_PREFIX, which a developer who has already set them for build.sh + * should not have to set again under another name. + * + *

On a Linux box the distribution's -dev package puts the headers where + * clang already looks, none of these probes match, and this adds nothing. + * + * @param openssl candidate prefixes for OpenSSL, in order of preference + * @param nghttp2 candidate prefixes for nghttp2 + */ + static List hostLibraryFlags(List openssl, List nghttp2) { + List out = new ArrayList(); + addPrefix(out, openssl, "include/openssl/sha.h"); + addPrefix(out, nghttp2, "include/nghttp2/nghttp2.h"); + return out; + } + + /** The first prefix that actually carries `probe`, as -I and -L. */ + private static void addPrefix(List out, List prefixes, String probe) { + if (prefixes == null) { + return; + } + for (int i = 0; i < prefixes.size(); i++) { + String prefix = prefixes.get(i); + if (prefix == null || prefix.length() == 0) { + continue; + } + if (new File(prefix, probe).isFile()) { + out.add("-I" + new File(prefix, "include").getAbsolutePath()); + out.add("-L" + new File(prefix, "lib").getAbsolutePath()); + return; + } + } + } + + private List opensslPrefixes() { + return prefixesFrom(System.getenv("OPENSSL_PREFIX"), + "/opt/homebrew/opt/openssl@3", "/usr/local/opt/openssl@3"); + } + + private List nghttp2Prefixes() { + return prefixesFrom(System.getenv("NGHTTP2_PREFIX"), + "/opt/homebrew/opt/libnghttp2", "/opt/homebrew/opt/nghttp2", + "/usr/local/opt/libnghttp2"); + } + + private static List prefixesFrom(String fromEnvironment, String... defaults) { + List out = new ArrayList(); + if (fromEnvironment != null && fromEnvironment.length() > 0) { + out.add(fromEnvironment); + } + out.addAll(Arrays.asList(defaults)); + return out; + } + + /** + * The codenameone-backend version this module depends on. + * + * Deliberately an error rather than a default when the dependency is absent: + * guessing a version here would translate a different runtime from the one the + * module was compiled and tested against. + */ + private String backendRuntimeVersion() throws MojoFailureException { + java.util.Set artifacts = project.getArtifacts(); + if (artifacts != null) { + for (Artifact artifact : artifacts) { + if ("com.codenameone".equals(artifact.getGroupId()) + && "codenameone-backend".equals(artifact.getArtifactId())) { + return artifact.getVersion(); + } + } + } + throw new MojoFailureException("This module does not depend on " + + "com.codenameone:codenameone-backend, so there is no backend " + + "runtime to translate. Add it as a dependency."); + } + + /** + * The JDK that compiles the sources for translation and runs the translator. + * + * ANY JDK 8 OR NEWER, which for almost every project means the one already + * running Maven and nothing to configure. This goal used to demand a JDK 8 + * and refuse to run without one, on the premise that a newer javac emits + * class files the translator cannot read. The premise was wrong: {@link + * #compile} passes -source 1.8 -target 1.8, so the class file version is 52 + * whichever javac produces it, and the bootclasspath that confines the build + * to the server-safe surface is enforced identically -- a reference to + * java.nio.file still fails to compile on 8, 17, 21 and 25 alike. The cost of + * the premise was paid entirely by the developer, who had to install a JDK + * from 2014 to package a server. + * + * The two properties are tried before the running JDK so an explicit choice + * still wins, and cn1.backend.jdk8 is among them so the setups that were + * required to set it keep working. + * + * WHAT THEY SELECT IS THE TWO FORKED STEPS -- {@link #compile}'s javac and + * {@link #translate}'s java -- and nothing else. The router and entry point + * that {@link #generateControllers} produces are compiled in process by the + * JDK running Maven, which JSR 199 gives no way to redirect. That costs + * nothing today, because every JDK from 8 up emits the class file version 52 + * the translator reads; see {@link #compile} for why it is also not worth + * forking, and why the error message there names Maven's own JDK. + */ + private File resolveJdk() throws MojoFailureException { + String[] configured = {jdkHome, jdk8Home}; + for (int i = 0; i < configured.length; i++) { + if (configured[i] != null && configured[i].length() > 0) { + File home = new File(configured[i]); + if (hasJavac(home)) { + return requireEightOrNewer(home); + } + } + } + File running = new File(System.getProperty("java.home")); + if (hasJavac(running)) { + return requireEightOrNewer(running); + } + // The Java 8 layout points java.home at the jre inside the JDK, where + // there is no compiler; it is one level up. + File parent = running.getParentFile(); + if (parent != null && hasJavac(parent)) { + return requireEightOrNewer(parent); + } + throw new MojoFailureException("Packaging a backend needs a JDK, and " + + System.getProperty("java.home") + " has no javac -- Maven is " + + "running on a JRE. Run it on a JDK, or point " + + "-Dcn1.backend.jdk at one."); + } + + /** + * Refuses a JDK older than 8, whose javac cannot emit the format the + * translator reads. + * + * Only reachable through an explicitly configured JDK: Maven itself needs 8 + * or newer, so the running one always passes. A javac that cannot be asked + * its version is ACCEPTED rather than refused -- the compile that follows + * reports what is actually wrong with it, and inventing a failure here would + * hide that. + */ + private File requireEightOrNewer(File home) throws MojoFailureException { + int major = javacMajor(home); + if (major > 0 && major < 8) { + throw new MojoFailureException("A JDK 8 or newer is required to " + + "package a backend; " + home + " is a JDK " + major + "."); + } + return home; + } + + /** + * The feature version of a JDK's javac, or -1 when it cannot be determined. + * + * Read by running it, not by parsing the path: a directory name says nothing + * reliable. Java 8 prints "javac 1.8.0_402" on stderr and later releases + * print "javac 21.0.2" on stdout, so both streams are read and both spellings + * are understood. + */ + private static int javacMajor(File home) { + try { + ProcessBuilder builder = new ProcessBuilder( + new File(home, "bin/javac").isFile() + ? new File(home, "bin/javac").getAbsolutePath() + : new File(home, "bin/javac.exe").getAbsolutePath(), + "-version"); + builder.redirectErrorStream(true); + Process process = builder.start(); + StringBuilder output = new StringBuilder(); + InputStream in = process.getInputStream(); + byte[] chunk = new byte[512]; + int n; + while ((n = in.read(chunk)) > 0) { + output.append(new String(chunk, 0, n, "UTF-8")); + } + process.waitFor(); + return parseJavacVersion(output.toString()); + } catch (IOException err) { + return -1; + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + return -1; + } + } + + /** Package private so the parsing is testable without a JDK to run. */ + static int parseJavacVersion(String output) { + if (output == null) { + return -1; + } + int at = output.indexOf("javac "); + if (at < 0) { + return -1; + } + String version = output.substring(at + "javac ".length()).trim(); + // "1.8.0_402" is Java 8; "21.0.2" is Java 21. The leading "1." is the old + // spelling and the number after it is the feature version. + if (version.startsWith("1.")) { + version = version.substring(2); + } + int end = 0; + while (end < version.length() && Character.isDigit(version.charAt(end))) { + end++; + } + if (end == 0) { + return -1; + } + try { + return Integer.parseInt(version.substring(0, end)); + } catch (NumberFormatException err) { + return -1; + } + } + + /** + * What to do about a javac that has dropped -source 8. + * + * Package private so a test can hold the wording to a remedy that WORKS. An + * earlier version of this sent the developer to -Dcn1.backend.jdk, which + * selects the two forked steps and not the in-process compile of the + * generated router and entry point, so following it moved the failure by one + * step and no further. See #compile. + */ + static final String SOURCE_EIGHT_REMOVED_HINT = + "This javac no longer accepts -source 8, which is the format the " + + "translator reads. Run Maven itself on a JDK that still does: " + + "-Dcn1.backend.jdk selects the compiler for this step and the " + + "translator, but the generated router and entry point are compiled " + + "in process by the JDK running Maven."; + + /** + * Whether a failed compile is javac refusing -source 8 outright, rather than + * anything about the sources. + * + * A future release will remove the option -- 21 and 25 already warn that it is + * obsolete -- and javac's own wording ("Source option 8 is no longer + * supported") names no way forward, so the caller adds one. + */ + private static boolean dropsSourceEight(MojoFailureException err) { + String message = err.getMessage(); + return message != null + && message.indexOf("Source option") >= 0 + && message.indexOf("no longer supported") >= 0; + } + + /** Windows names it javac.exe, and a JRE has neither. */ + private static boolean hasJavac(File home) { + return new File(home, "bin/javac").isFile() + || new File(home, "bin/javac.exe").isFile(); + } + + File resolve(String groupId, String artifactId, String version, String classifier) + throws MojoExecutionException { + Artifact artifact = repositorySystem.createArtifactWithClassifier( + groupId, artifactId, version, "jar", classifier); + ArtifactResolutionRequest request = new ArtifactResolutionRequest(); + request.setArtifact(artifact); + request.setLocalRepository(localRepository); + request.setRemoteRepositories(project.getRemoteArtifactRepositories()); + ArtifactResolutionResult result = repositorySystem.resolve(request); + if (!result.isSuccess() || artifact.getFile() == null) { + throw new MojoExecutionException("Could not resolve " + groupId + ":" + + artifactId + ":" + version + ":" + classifier); + } + return artifact.getFile(); + } + + /** + * Unpacks a jar. Entries under cn1-native/ go to `nativeTarget` when one is + * given, because the C belongs in the translator's source root rather than on + * the Java source path. + */ + void unzip(File jar, File javaTarget, File nativeTarget) + throws MojoExecutionException { + unzip(jar, javaTarget, nativeTarget, false); + } + + /** + * @param firstWins leave an entry alone when something is already at its + * destination, which is how a classpath resolves a class two + * entries both carry + */ + private void unzip(File jar, File javaTarget, File nativeTarget, boolean firstWins) + throws MojoExecutionException { + try { + ZipFile zip = new ZipFile(jar); + try { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.isDirectory()) { + continue; + } + String name = entry.getName(); + File destination; + if (name.startsWith("cn1-native/")) { + if (nativeTarget == null) { + continue; + } + destination = resolveInside(nativeTarget, + name.substring("cn1-native/".length()), jar, name); + } else if (name.startsWith("META-INF/")) { + continue; + } else { + destination = resolveInside(javaTarget, name, jar, name); + } + if (firstWins && destination.isFile()) { + continue; + } + mkdirs(destination.getParentFile()); + InputStream in = zip.getInputStream(entry); + try { + copy(in, destination); + } finally { + in.close(); + } + } + } finally { + zip.close(); + } + } catch (IOException err) { + throw new MojoExecutionException("Could not unpack " + jar, err); + } + } + + /** + * The entry's destination, proven to be inside the directory it unpacks into. + * + * An archive entry name is attacker-controlled data, not a path this build + * chose: an entry called `../../../../etc/whatever` makes `new File(root, name)` + * resolve outside `root`, so unpacking writes wherever the entry says. That is + * Zip Slip, and here it would run with the developer's privileges during an + * ordinary `mvn package` against whatever jar the coordinates resolved to. + * + * Compared after canonicalisation rather than on the raw string, because `..` + * is not the only way out -- a symlinked parent resolves elsewhere too, and the + * textual check passes for both. The separator is appended to the root so a + * sibling whose name merely starts with it ("/tmp/outdir-evil" against + * "/tmp/outdir") cannot satisfy the prefix test. + */ + private static File resolveInside(File root, String relative, File jar, String entryName) + throws IOException { + File destination = new File(root, relative); + String prefix = root.getCanonicalPath() + File.separator; + String resolved = destination.getCanonicalPath(); + if (!resolved.startsWith(prefix)) { + throw new IOException("Refusing to unpack " + jar + ": entry \"" + entryName + + "\" resolves to " + resolved + ", outside " + root.getCanonicalPath()); + } + return destination; + } + + private static void copy(InputStream in, File destination) throws IOException { + OutputStream out = new FileOutputStream(destination); + try { + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + out.write(chunk, 0, n); + } + } finally { + out.close(); + } + } + + /** As copyDirectory, but never replacing a file that is already there. */ + private void copyDirectoryFirstWins(File from, File to) throws MojoExecutionException { + copyDirectoryFirstWins(from, to, null); + } + + /** + * @param nativeTarget where a top-level cn1-native subtree belongs, or null + * when this is already below one + * + *

The same routing the jar branch does, and for the same reason: the + * translator has no code that looks for cn1-native inside a class input, and + * only nativeSources is copied into its C source root. Copied as it stood, + * a reactor module's C was staged among the classes where nothing reads it -- + * and a native whose C is absent is not a link error, because a Java native + * method is kept alive BY its symbol appearing in the native sources, so the + * dead-code pass drops the method and the build stays green with the feature + * inert. The same dependency packaged correctly once it was installed as a + * jar and consumed that way, which is the worst shape for this to take. + */ + private void copyDirectoryFirstWins(File from, File to, File nativeTarget) + throws MojoExecutionException { + File[] children = from.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (nativeTarget != null && child.isDirectory() + && "cn1-native".equals(child.getName())) { + mkdirs(nativeTarget); + // Without the native target below it: cn1-native is matched at the + // root only, exactly as the jar branch matches the prefix. + copyDirectoryFirstWins(child, nativeTarget); + continue; + } + File destination = new File(to, child.getName()); + if (child.isDirectory()) { + mkdirs(destination); + copyDirectoryFirstWins(child, destination); + continue; + } + if (destination.isFile()) { + continue; + } + try { + InputStream in = new java.io.FileInputStream(child); + try { + copy(in, destination); + } finally { + in.close(); + } + } catch (IOException err) { + throw new MojoExecutionException("Could not copy " + child, err); + } + } + } + + void copyDirectory(File from, File to) throws MojoExecutionException { + File[] children = from.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + File destination = new File(to, child.getName()); + if (child.isDirectory()) { + mkdirs(destination); + copyDirectory(child, destination); + continue; + } + try { + InputStream in = new java.io.FileInputStream(child); + try { + copy(in, destination); + } finally { + in.close(); + } + } catch (IOException err) { + throw new MojoExecutionException("Could not copy " + child, err); + } + } + } + + void collectJava(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectJava(child, out); + } else if (child.getName().endsWith(".java")) { + out.add(child.getAbsolutePath()); + } + } + } + + void run(List command, File directory, String what) + throws MojoExecutionException, MojoFailureException { + try { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(directory); + builder.redirectErrorStream(true); + Process process = builder.start(); + StringBuilder output = new StringBuilder(); + InputStream in = process.getInputStream(); + byte[] chunk = new byte[8192]; + int n; + while ((n = in.read(chunk)) > 0) { + output.append(new String(chunk, 0, n, "UTF-8")); + } + int status = process.waitFor(); + if (status != 0) { + throw new MojoFailureException("Could not " + what + ":\n" + output); + } + if (output.length() > 0) { + getLog().debug(output.toString()); + } + } catch (IOException err) { + throw new MojoExecutionException("Could not " + what, err); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted while trying to " + what, err); + } + } + + /** + * Removes each directory and its contents, and refuses to continue if one + * survives. + * + * The emptiness is the point, and it used to be assumed: File.delete returns + * false for a locked file on Windows or anything under a read-only directory, + * nothing looked at that, and the stale .class stayed where ClassScanner, + * requireMainClass and the translator would all find it. A controller or an + * entry point deleted from the source tree is then still packaged, so the + * build ships the previous implementation and says nothing. Checking the + * result rather than each delete catches every reason one can survive. + */ + static void emptyDirs(File... dirs) throws MojoExecutionException { + for (File dir : dirs) { + deleteTree(dir); + if (dir == null || !dir.exists()) { + continue; + } + String[] left = dir.list(); + if (left != null && left.length > 0) { + throw new MojoExecutionException("Could not empty " + dir + + ": " + left.length + " entr" + (left.length == 1 ? "y" : "ies") + + " could not be deleted, and building over them would package " + + "classes that are no longer in the source tree."); + } + } + } + + private static void deleteTree(File file) { + if (file == null || !file.exists()) { + return; + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteTree(child); + } + } + // The result is checked by emptyDirs, which looks at what actually + // survived rather than at each delete: a directory that could not be + // removed but is empty is harmless, and one that still holds a class is + // not, whatever the reason. + file.delete(); + } + + static void mkdirs(File... dirs) { + for (File dir : dirs) { + if (dir != null && !dir.isDirectory()) { + dir.mkdirs(); + } + } + } + + private static String join(List parts, String separator) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < parts.size(); iter++) { + if (iter > 0) { + out.append(separator); + } + out.append(parts.get(iter)); + } + return out.toString(); + } +} 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 58bc7033b51..3818a323510 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 @@ -22,43 +22,10 @@ */ package com.codename1.maven; -import org.apache.maven.artifact.Artifact; -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.Execute; 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.model.Plugin; -import org.apache.maven.project.MavenProject; -import org.codehaus.plexus.util.xml.Xpp3Dom; -import org.apache.maven.repository.RepositorySystem; -import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; -import org.apache.maven.artifact.resolver.ArtifactResolutionResult; -import org.apache.maven.artifact.repository.ArtifactRepository; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -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 com.codename1.maven.processors.OrmAnnotationProcessor; -import com.codename1.maven.processors.RestControllerAnnotationProcessor; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Enumeration; -import java.util.List; -import java.util.Map; -import java.util.Properties; -import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; /** * Translates a backend module to C and compiles it to a native binary: @@ -92,1281 +59,5 @@ // target/classes. @Execute(phase = LifecyclePhase.COMPILE) @Mojo(name = "backend-package", requiresDependencyResolution = ResolutionScope.COMPILE) -public class BackendPackageMojo 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; - - /** - * The class whose main() becomes the program's entry point. - * - * Optional. A module whose server is written as `@RestController` classes has no - * main of its own -- one is generated from them -- and naming a class that does - * not exist is worse than leaving this out. - */ - @Parameter(property = "cn1.backend.mainClass") - private String mainClass; - - /** - * Where the binary goes. Defaults to target/<artifactId>. - */ - @Parameter(property = "cn1.backend.output") - private File output; - - /** - * A Linux deployment target -- musl-x86_64, musl-arm64, glibc-x86_64, - * glibc-arm64 -- built in a container. Omitted, it compiles for this machine - * with the host compiler, which is what a developer wants and what CI checks. - */ - @Parameter(property = "cn1.backend.target") - private String target; - - /** - * The JDK whose javac compiles for translation and whose java runs the - * translator. Defaults to the JDK running Maven. - * - * Any JDK 8 or newer works; see {@link #resolveJdk()} for why this is no - * longer pinned to a JDK 8. - */ - @Parameter(property = "cn1.backend.jdk") - private String jdkHome; - - /** - * The JDK 8 this goal used to require. - * - * Kept, and still honoured, because projects and CI jobs are setting it. It is - * no longer a requirement: with neither this nor cn1.backend.jdk set, the JDK - * running Maven is what compiles and translates. - */ - @Parameter(property = "cn1.backend.jdk8", defaultValue = "${env.JDK_8_HOME}") - private String jdk8Home; - - /** Extra flags for the C compiler. */ - @Parameter(property = "cn1.backend.cflags") - private String cflags; - - /** - * Whether to link the bundled SQLite engine. Off saves about 2MB in a service - * that talks to PostgreSQL or MySQL instead, which speak their wire protocols - * with no engine linked at all. - */ - @Parameter(property = "cn1.backend.sqlite", defaultValue = "true") - private boolean sqlite; - - /** - * Whether a failed cast throws. - * - * On by default here and off for app targets, which is the one place the - * server build departs from the mobile one deliberately: on a phone a bad cast - * costs one user a crash, and on a server the object with the wrong type - * arrived from the network, so reading its fields as another type kills every - * connection the process was serving. - */ - @Parameter(property = "cn1.backend.checkedCasts", defaultValue = "true") - private boolean checkedCasts; - - /** - * Whether the packaged server carries the development MCP tools. Off by - * default: they read the database and call the server on an agent's behalf, - * and a production binary should not contain them at all -- not merely have - * them switched off. {@code cn1:backend} runs the JVM build, which has them. - */ - @Parameter(property = "cn1.backend.devTools", defaultValue = "false") - private boolean devTools; - - public void execute() throws MojoExecutionException, MojoFailureException { - File jdk = resolveJdk(); - File work = new File(project.getBuild().getDirectory(), "cn1-backend"); - File classes = new File(work, "classes"); - File javaApi = new File(work, "javaapi-classes"); - File runtimeSources = new File(work, "runtime-src"); - File nativeSources = new File(work, "native"); - File translated = new File(work, "translated"); - File dependencyClasses = new File(work, "dependency-classes"); - // Emptied, not just created. Every one of these is derived, and nothing here - // removes a file that stopped being produced: a renamed or deleted source - // left its old .class behind, the translator still read it, and even - // requireMainClass accepted a main class the module no longer had -- so the - // package that came out was the previous implementation. Rebuilding from - // clean costs nothing, since neither the javac nor the clang pass below was - // ever incremental. - emptyDirs(classes, javaApi, runtimeSources, nativeSources, translated, - dependencyClasses); - mkdirs(work, classes, javaApi, runtimeSources, nativeSources, translated, - dependencyClasses); - - // The version of the runtime THIS MODULE compiles against, not the - // module's own: the sources handed to the translator have to be the same - // ones behind the classes the developer just built against, or the local - // run and the deployed binary are different programs. - String runtimeVersion = backendRuntimeVersion(); - File runtimeJar = resolve("com.codenameone", "codenameone-backend", - runtimeVersion, "parparvm-sources"); - unzip(runtimeJar, runtimeSources, nativeSources); - File parparvmBundle = resolve("com.codenameone", "codenameone-parparvm", - runtimeVersion, "bundle"); - File bundleDir = new File(work, "parparvm"); - mkdirs(bundleDir); - unzip(parparvmBundle, bundleDir, null); - File compilerJar = new File(bundleDir, "parparvm-compiler.jar"); - File javaApiJar = new File(bundleDir, "parparvm-java-api.jar"); - if (!compilerJar.isFile() || !javaApiJar.isFile()) { - throw new MojoExecutionException("The ParparVM bundle is missing its " - + "compiler or JavaAPI jar: " + parparvmBundle); - } - unzip(javaApiJar, javaApi, null); - - compile(jdk, javaApi, runtimeSources, classes); - generateControllers(classes, work); - requireMainClass(classes); - translate(jdk, compilerJar, javaApi, classes, nativeSources, translated, - dependencyClasses); - File binary = output != null ? output - : new File(project.getBuild().getDirectory(), project.getArtifactId()); - link(translated, binary); - 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. - */ - /** - * Generates the routers and the bootstrap for this module's `@RestController` - * classes, into the directory this goal has just compiled into. - * - * Not left to the `process-annotations` goal, which writes into Maven's - * target/classes: that is a different build, made against a JDK rather than - * against the backend's class library, and the translator never reads it. A - * router generated there would be absent from the binary while looking present - * in the project. Generating into the tree that is about to be translated is - * what makes the wiring real. - * - * Sets mainClass to the generated bootstrap when the module did not name one. - */ - private void generateControllers(File classes, File work) throws MojoExecutionException { - Map index; - try { - index = ClassScanner.scan(classes); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not scan the compiled backend classes: " - + err.getMessage(), err); - } - RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor(); - processor.setDevTools(devTools); - ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index, - getLog(), project.getBasedir(), new Properties(), mainClass, - java.util.Collections.emptyList(), "UTF-8", - compileClasspathWithoutRuntime()); - // THE ENTITIES FIRST, into this same tree. - // - // The entry point generated below references cn1app.BackendDaoBootstrap - // whenever the module has an @Entity, because that reference is the only - // thing that keeps the generated daos in the binary -- the translator - // drops a class nothing names. This goal empties and rebuilds its own - // class tree and never copies Maven's target/classes into it, so a dao - // generated by the process-annotations goal is not here: without this - // pass the entry point names a class that does not exist and packaging - // fails at its own javac, on any project that has both an entity and a - // controller. - // - // The flavour is forced because the classpath cannot answer for it here; - // see OrmAnnotationProcessor#setBackendFlavour. - OrmAnnotationProcessor entities = new OrmAnnotationProcessor(); - entities.setBackendFlavour(true); - try { - entities.start(ctx); - for (AnnotatedClass cls : index.values()) { - if (!cls.getClassAnnotations().isEmpty()) { - entities.processClass(cls, ctx); - } - } - entities.finish(ctx); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not process @Entity: " - + err.getMessage(), err); - } - // Re-scanned, so the controller pass sees the daos and the bootstrap the - // pass above just wrote. hasGeneratedDaos() reads this index. - try { - index = ClassScanner.scan(classes); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not re-scan the backend classes after " - + "generating the entity daos: " + err.getMessage(), err); - } - try { - processor.start(ctx); - for (AnnotatedClass cls : index.values()) { - if (!cls.getClassAnnotations().isEmpty()) { - processor.processClass(cls, ctx); - } - } - // finish() runs the bean pass -- the wiring, the rewritten classes and - // the classes generated beside them -- into this same tree. - processor.finish(ctx); - entities.enhance(ctx); - } catch (ProcessingException err) { - throw new MojoExecutionException("Could not process @RestController: " - + err.getMessage(), err); - } - if (ctx.hasErrors()) { - StringBuilder sb = new StringBuilder("The backend's annotations could not be processed:"); - for (ProcessorContext.ProcessingError e : ctx.getErrors()) { - sb.append("\n ").append(e); - } - throw new MojoExecutionException(sb.toString()); - } - byte[] generated = ctx.getEmittedResources() - .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE); - if (generated == null) { - return; - } - String name; - try { - name = new String(generated, "UTF-8").trim(); - } catch (java.io.UnsupportedEncodingException err) { - throw new MojoExecutionException("UTF-8 is required of every JDK", err); - } - if (mainClass == null || mainClass.length() == 0) { - mainClass = name; - getLog().info("cn1: entry point " + name + ", generated from @RestController"); - } - } - - /** - * Fails here, with the reason, rather than inside the translator. - * - * The compile below reads .java and only .java, on purpose: recompiling against - * the JavaAPI bootclasspath is what turns "this backend uses a class the runtime - * does not have" into a compile error instead of a link failure on the device, - * and reusing the jar Maven already built would give that up. The cost is that a - * main class written in Kotlin -- which `cn1:backend` runs happily, because that - * goal is a JVM launch -- never reaches this directory, and the translator's own - * complaint about it names neither Kotlin nor the reason. So say it plainly. The - * developer guide's "Limits worth knowing" carries the same statement. - */ - /** - * The encoding this module's sources are actually written in. - * - *

These are the SAME sources the lifecycle has already compiled, so - * reading them differently here is a second, disagreeing compilation of one - * tree: a module that declares another encoding either fails packaging on - * bytes javac accepted a phase earlier, or -- worse, because nothing says so - * -- translates string literals and identifiers that are not the ones the JVM - * build produced. - * - *

Resolved the way the compiler plugin resolves it, most specific first: - * an explicit <encoding> on maven-compiler-plugin, then - * project.build.sourceEncoding, and UTF-8 only when the module says nothing. - * The platform default is deliberately not the last resort -- it makes the - * build depend on the machine that runs it, which is the reason Maven warns - * about it. - */ - private String sourceEncoding() { - 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"); - if (encoding != null && encoding.getValue() != null) { - String declared = encoding.getValue().trim(); - // A configuration that is still a property reference is one Maven - // would have interpolated; an uninterpolated ${...} names no - // charset, so fall through rather than hand javac a literal. - if (declared.length() > 0 && declared.indexOf("${") < 0) { - return declared; - } - } - } - String property = project.getProperties() == null ? null - : project.getProperties().getProperty("project.build.sourceEncoding"); - if (property != null && property.trim().length() > 0) { - return property.trim(); - } - return "UTF-8"; - } - - private void requireMainClass(File classes) throws MojoFailureException { - if (mainClass == null || mainClass.length() == 0) { - throw new MojoFailureException("No entry point: set , or annotate " - + "a class with @RestController and let the bootstrap be generated " - + "from it"); - } - if (new File(classes, mainClass.replace('.', '/') + ".class").isFile()) { - return; - } - throw new MojoFailureException("The main class " + mainClass + " was not " - + "produced by the backend compile. This goal compiles Java sources " - + "against the backend class library, so a main class written in " - + "Kotlin or generated into the build output is not visible to it " - + "yet -- write the entry point in Java, or keep it on the JVM with " - + "cn1:backend"); - } - - private void compile(File jdk, File javaApi, File runtimeSources, File classes) - throws MojoExecutionException, MojoFailureException { - List sources = new ArrayList(); - for (Object root : project.getCompileSourceRoots()) { - collectJava(new File(String.valueOf(root)), sources); - } - collectJava(runtimeSources, sources); - if (sources.isEmpty()) { - throw new MojoFailureException("No Java sources to compile"); - } - - List command = new ArrayList(Arrays.asList( - new File(jdk, "bin/javac").getAbsolutePath(), - "-nowarn", "-encoding", sourceEncoding(), - "-bootclasspath", javaApi.getAbsolutePath(), - "-source", "1.8", "-target", "1.8", - "-d", classes.getAbsolutePath())); - // The module's own dependencies, MINUS the backend runtime: its compiled - // form was built against a JDK, and the sources unpacked above are the - // half that belongs on this bootclasspath. - List classpath = new ArrayList(); - for (Object element : compileClasspathWithoutRuntime()) { - classpath.add(String.valueOf(element)); - } - if (!classpath.isEmpty()) { - command.add("-classpath"); - command.add(join(classpath, File.pathSeparator)); - } - command.addAll(sources); - // -source/-target 8 is the translator's input format and is not a property - // of the compiler running here: every javac from 8 up emits the same class - // file version 52 for it. A javac that has dropped the option says so in - // its own words and names no remedy, so the remedy is added to it. - // - // THE REMEDY IS THE JDK RUNNING MAVEN, not -Dcn1.backend.jdk, even though - // that property is what selects the compiler on this line. Only the two - // FORKED steps read it -- this javac and the translator's java. - // generateControllers() compiles the router and the entry point in - // process, through ToolProvider.getSystemJavaCompiler(), which is the - // Maven JVM's compiler and cannot be pointed anywhere; it asks for - // -source 1.8 as well, so it fails next on a compiler that has dropped - // it. Naming the property here would send a developer to a setting that - // moves the failure by one step and no further. - // - // Routing that in-process compile through the selected JDK is not the - // answer either. A release that removes -source 8 takes it away from the - // Maven JVM too, and this build needs a javac that emits class file - // version 52 in many more places than this goal -- codenameone-core - // compiles at 1.5. The whole toolchain moves then, not one mojo. - try { - run(command, project.getBasedir(), "compile the backend sources"); - } catch (MojoFailureException err) { - throw dropsSourceEight(err) - ? new MojoFailureException(err.getMessage() + "\n\n" - + SOURCE_EIGHT_REMOVED_HINT, err) - : err; - } - stageResources(classes); - } - - /** - * Copies the module's resources in beside the classes just compiled. - * - * This directory is emptied and then filled from .java alone, and the project's - * own output directory is excluded from the translator input on purpose -- its - * classes were built against a JDK. The consequence was that anything read from - * the classpath, a properties or configuration file, was present under - * cn1:backend and simply absent from the packaged binary. Nothing failed at - * build time; the resource was just not there at runtime. - * - * Everything EXCEPT .class is taken, which is exactly the resources and none of - * the JDK-compiled code. - * - * ONLY Maven's processed output, never the raw resource directories. Those - * directories are what a / selects FROM, so copying them - * wholesale packaged the files the build was configured to leave out -- an - * environment file or a secret excluded on purpose would have gone into the - * executable, and the later overlay could not remove it. The processed copy is - * the answer Maven already computed. - */ - private void stageResources(File classes) throws MojoExecutionException { - File processed = new File(project.getBuild().getOutputDirectory()); - if (!processed.isDirectory()) { - // Nothing has processed the resources, so there are none to stage and - // nothing to guess at. Said out loud, because a resource silently absent - // from the binary is the failure this whole step exists to prevent. - if (!project.getBuild().getResources().isEmpty()) { - getLog().warn("cn1: this module declares resources but " - + processed + " does not exist, so none are packaged. Run " - + "process-resources first, or invoke this through the " - + "lifecycle rather than as a bare goal."); - } - return; - } - try { - int staged = copyNonClasses(processed, classes); - // Staged is not the same as READABLE, and the difference is silent. - // These files reach the translator, so anything that reads them at - // BUILD time works -- but the backend translates as app type "clean", - // and only the linux and windows types embed classpath resources into - // the binary. The clean runtime's Class.getResourceAsStream returns - // null unconditionally, so getResourceAsStream finds the file under - // cn1:backend, on the JVM, and finds nothing in the packaged - // executable. Said out loud rather than left to be discovered in - // production; embedding them is a change to the translator and the - // shared runtime, not to this goal. - if (staged > 0) { - getLog().warn("cn1: staged " + staged + " resource file(s) for translation, " - + "but a packaged backend cannot READ them: getResourceAsStream " - + "answers null in the translated runtime, though it works under " - + "cn1:backend. Read configuration from a file path or the " - + "environment instead of the classpath."); - } - } catch (IOException err) { - // A resource that cannot be staged is a packaging failure, not a note: - // the executable would be reported as built while missing something - // cn1:backend has, and the difference would first appear in production. - throw new MojoExecutionException("Could not stage the processed resources " - + "from " + processed + " into " + classes, err); - } - } - - /** @return how many non-class files were copied. */ - private int copyNonClasses(File from, File to) throws IOException { - if (from == null || !from.isDirectory()) { - return 0; - } - File[] children = from.listFiles(); - if (children == null) { - return 0; - } - int copied = 0; - for (File child : children) { - File target = new File(to, child.getName()); - if (child.isDirectory()) { - target.mkdirs(); - copied += copyNonClasses(child, target); - } else if (!child.getName().endsWith(".class")) { - copyFile(child, target); - copied++; - } - } - return copied; - } - - private static void copyFile(File from, File to) throws IOException { - InputStream in = new java.io.FileInputStream(from); - try { - OutputStream out = new java.io.FileOutputStream(to); - try { - byte[] chunk = new byte[8192]; - int n; - while ((n = in.read(chunk)) > 0) { - out.write(chunk, 0, n); - } - } finally { - out.close(); - } - } finally { - in.close(); - } - } - - private List compileClasspathWithoutRuntime() throws MojoExecutionException { - List classpath = new ArrayList(); - try { - for (Object element : project.getCompileClasspathElements()) { - classpath.add(String.valueOf(element)); - } - } catch (Exception err) { - throw new MojoExecutionException("Could not resolve the compile classpath", err); - } - return withoutRuntime(classpath, runtimeArtifactFile(), - project.getBuild().getOutputDirectory()); - } - - /** - * The classpath without the backend runtime and without this module's own - * output. - * - *

IDENTIFIED BY FILE, not by looking for "codenameone-backend" anywhere in - * a path. A project checked out under a directory whose name contains that -- - * /work/codenameone-backend-demo/ is the obvious one -- has EVERY reactor - * dependency below it match, so a backend depending on a sibling contract - * module lost it from both the compile classpath and the translation, and - * failed on classes it plainly depends on. The name of a directory somewhere - * above the project is not something a build should read meaning into. - * - *

The runtime is left out because its sources are compiled into `classes` - * already; the module's own output for the same reason. - * - * @param runtime the resolved runtime artifact, or null when it cannot be - * located -- then nothing is dropped for it, which is - * duplicate work rather than a missing class - */ - static List withoutRuntime(List classpath, File runtime, String ownOutput) { - List out = new ArrayList(); - File runtimeFile = runtime == null ? null : runtime.getAbsoluteFile(); - File output = ownOutput == null ? null : new File(ownOutput).getAbsoluteFile(); - for (int i = 0; i < classpath.size(); i++) { - String path = classpath.get(i); - File element = new File(path).getAbsoluteFile(); - if (runtimeFile != null && runtimeFile.equals(element)) { - continue; - } - if (output != null && output.equals(element)) { - continue; - } - out.add(path); - } - return out; - } - - /** The resolved file of com.codenameone:codenameone-backend, or null. */ - private File runtimeArtifactFile() { - java.util.Set artifacts = project.getArtifacts(); - if (artifacts != null) { - for (Artifact artifact : artifacts) { - if ("com.codenameone".equals(artifact.getGroupId()) - && "codenameone-backend".equals(artifact.getArtifactId())) { - return artifact.getFile(); - } - } - } - return null; - } - - /** - * The compile classpath staged as ONE directory the translator can read. - * - * ByteCodeTranslator walks its inputs with File.listFiles, which answers NULL - * for a jar -- and the walk reads null as an empty directory, so a dependency - * resolved from the repository as a jar contributed nothing at all, without a - * word. The build then failed much later, while linking, on the symbols of - * classes the translator had never been shown. A module in the same reactor - * resolves to its target/classes and worked, which is why the generated - * project's own contract module never showed this. - * - *

ONE TREE, FIRST WINS, IN CLASSPATH ORDER, and not a list of inputs. - * Parser.classIndex keeps the first definition of a class it parsed, so the - * order of the translator's inputs IS precedence -- and javac resolved the - * same classpath the same way, so anything that reorders it compiles against - * one definition and translates another. Staging settles it on disk instead: - * whatever arrives first is what is there, every later copy is skipped, and - * there is no order left to get wrong. It also means a class two dependencies - * both carry is PARSED once rather than twice, which is where duplicate - * symbols came from. - * - *

Directories are copied rather than passed through for that reason alone. - * They cost a copy of their class files per build, which is a reactor - * module's output and small beside the translation that follows. - * - *

cn1-native goes where the runtime jar's natives go, so a dependency that - * ships them is built rather than dropped just as quietly. META-INF is - * skipped out of jars by the same unpacking the runtime gets; a directory is - * copied as it stands, which is what passing it as an input already did. - * - * @param classpath compile classpath elements, in classpath order - * @param staged directory to stage into; assumed empty - * @param nativeSources where a dependency's cn1-native entries belong - */ - private List stageDependencyClasses(List classpath, File staged, - File nativeSources) throws MojoExecutionException { - List out = new ArrayList(); - boolean any = false; - for (int i = 0; i < classpath.size(); i++) { - File element = new File(classpath.get(i)); - if (element.isDirectory()) { - copyDirectoryFirstWins(element, staged, nativeSources); - any = true; - } else if (element.isFile()) { - unzip(element, staged, nativeSources, true); - any = true; - } - // An entry that is neither is one javac will complain about; there is - // nothing here to stage and nothing to say that it will not say. - } - if (any) { - out.add(staged.getAbsolutePath()); - } - return out; - } - - private void translate(File jdk, File compilerJar, File javaApi, File classes, - File nativeSources, File translated, File dependencyClasses) - throws MojoExecutionException, MojoFailureException { - String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); - String packageName = mainClass.lastIndexOf('.') < 0 ? "" - : mainClass.substring(0, mainClass.lastIndexOf('.')); - - // BEFORE the natives are copied below, because a dependency that ships - // cn1-native adds to them. - List dependencyInputs = stageDependencyClasses( - compileClasspathWithoutRuntime(), dependencyClasses, nativeSources); - - // The C has to be in the source root BEFORE the translator runs: it reads - // the directory to decide which native-only Java methods to keep, and the - // signature verifier checks every declared native against an actual - // implementation. - File sourceDir = new File(translated, "dist/" + simpleName + "-src"); - mkdirs(sourceDir); - copyDirectory(nativeSources, sourceDir); - - List command = new ArrayList(); - command.add(new File(jdk, "bin/java").getAbsolutePath()); - if (sqlite) { - command.add("-Dcn1.sqlite=true"); - } - if (checkedCasts) { - command.add("-Dcn1.checkedCasts=true"); - } - command.add("-cp"); - command.add(compilerJar.getAbsolutePath()); - command.add("com.codename1.tools.translator.ByteCodeTranslator"); - command.add("clean"); - // The module's dependencies belong on the translator's input, not only on - // javac's classpath. Without them a backend that uses a type from another - // module -- the shared contract or DTO module the generated project - // recommends -- compiles here and then fails to translate, because javac - // resolved the type from a jar whose bytecode the translator never sees. - // The runtime is excluded for the same reason it is excluded from javac's - // classpath: its sources are compiled into `classes` already. - StringBuilder translatorInput = new StringBuilder(); - translatorInput.append(javaApi.getAbsolutePath()) - .append(';').append(classes.getAbsolutePath()); - for (int i = 0; i < dependencyInputs.size(); i++) { - translatorInput.append(';').append(dependencyInputs.get(i)); - } - command.add(translatorInput.toString()); - command.add(translated.getAbsolutePath()); - command.add(simpleName); - command.add(packageName); - command.add(simpleName); - command.add("1.0"); - command.add("clean"); - command.add("none"); - run(command, project.getBasedir(), "translate the backend to C"); - } - - private void link(File translated, File binary) - throws MojoExecutionException, MojoFailureException { - String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1); - File sourceDir = new File(translated, "dist/" + simpleName + "-src"); - // Kept as a loud failure rather than dropped: the parameter names a real - // capability, and silently ignoring -Dcn1.backend.target would hand back a - // host binary labelled as a cross-compiled one. The script named here lives in - // the Codename One repository, not in a generated project, which is why the - // message says where it is instead of assuming it is on hand. - if (target != null && target.length() > 0) { - throw new MojoFailureException("cn1.backend.target is not supported from " - + "this goal yet: it builds for the machine it runs on. The " - + "cross-compiled targets (musl-x86_64, musl-arm64, glibc-x86_64, " - + "glibc-arm64) are produced by package.sh in the Codename One " - + "repository, which drives one container image per target; run " - + "this goal inside a container of the target flavour to get the " - + "same artifact here"); - } - List command = new ArrayList(Arrays.asList( - "clang", "-O3", "-w", - // Mandatory for generated C: Java arithmetic wraps, and clang -O3 - // provably miscompiles the output without these. - "-fwrapv", "-fno-strict-aliasing", - "-fno-builtin-fmod", "-fno-builtin-fmodf")); - if (!sqlite) { - // Turning the engine OFF is two changes, not one. Without - // -Dcn1.sqlite=true the translator leaves cn1_sqlite3.h out, but - // cn1_backend_db.c is copied and compiled either way -- and its - // SQLite branch includes that header unconditionally, so the compile - // fails with "cn1_sqlite3.h file not found" and the option advertised - // as saving the engine could not produce a binary at all. The macro - // is what compiles that file to stubs instead, which answer "could - // not open" and become an IOException, rather than dropping the Db - // natives and taking their Java methods with them. build.sh has - // always set both; this half had only the first. - command.add("-DCN1_BACKEND_NO_SQLITE"); - } - if (cflags != null && cflags.trim().length() > 0) { - command.addAll(Arrays.asList(cflags.trim().split("\\s+"))); - } - // Before -I on the generated sources, which is where build.sh puts them. - command.addAll(hostLibraryFlags(opensslPrefixes(), nghttp2Prefixes())); - command.add("-I" + sourceDir.getAbsolutePath()); - File[] cFiles = sourceDir.listFiles(); - if (cFiles == null) { - throw new MojoExecutionException("The translator produced nothing in " + sourceDir); - } - for (File file : cFiles) { - String name = file.getName(); - // .S as well as .c, which is what vm/backend/build.sh compiles. The - // translator always emits cn1_virtual_thread_asm.S, and - // cn1_virtual_thread.c calls cn1VirtualThreadSwitch out of it, so a - // command that passed only .c reached the linker with that symbol - // undefined and this goal could not produce a binary at all. - // Generated resource assembly is in the same position. - if (name.endsWith(".c") || name.endsWith(".S") || name.endsWith(".s")) { - command.add(file.getAbsolutePath()); - } - } - command.addAll(Arrays.asList("-lm", "-lpthread", - "-lcurl", "-lssl", "-lcrypto", "-lnghttp2")); - command.add("-o"); - command.add(binary.getAbsolutePath()); - run(command, project.getBasedir(), "compile the generated C"); - } - - /** - * -I and -L for the TLS and HTTP/2 libraries, where this machine keeps them. - * - * macOS ships libcrypto WITHOUT its headers, so a stock machine with the - * usual Homebrew OpenSSL could not compile the generated C at all: the goal - * the documentation tells a developer to run failed on openssl/ssl.h, and the - * only way out was to work out cn1.backend.cflags for themselves. The same - * prefixes and the same probe headers as vm/backend/build.sh, so the two ways - * of building a backend look in the same places -- including OPENSSL_PREFIX - * and NGHTTP2_PREFIX, which a developer who has already set them for build.sh - * should not have to set again under another name. - * - *

On a Linux box the distribution's -dev package puts the headers where - * clang already looks, none of these probes match, and this adds nothing. - * - * @param openssl candidate prefixes for OpenSSL, in order of preference - * @param nghttp2 candidate prefixes for nghttp2 - */ - static List hostLibraryFlags(List openssl, List nghttp2) { - List out = new ArrayList(); - addPrefix(out, openssl, "include/openssl/sha.h"); - addPrefix(out, nghttp2, "include/nghttp2/nghttp2.h"); - return out; - } - - /** The first prefix that actually carries `probe`, as -I and -L. */ - private static void addPrefix(List out, List prefixes, String probe) { - if (prefixes == null) { - return; - } - for (int i = 0; i < prefixes.size(); i++) { - String prefix = prefixes.get(i); - if (prefix == null || prefix.length() == 0) { - continue; - } - if (new File(prefix, probe).isFile()) { - out.add("-I" + new File(prefix, "include").getAbsolutePath()); - out.add("-L" + new File(prefix, "lib").getAbsolutePath()); - return; - } - } - } - - private List opensslPrefixes() { - return prefixesFrom(System.getenv("OPENSSL_PREFIX"), - "/opt/homebrew/opt/openssl@3", "/usr/local/opt/openssl@3"); - } - - private List nghttp2Prefixes() { - return prefixesFrom(System.getenv("NGHTTP2_PREFIX"), - "/opt/homebrew/opt/libnghttp2", "/opt/homebrew/opt/nghttp2", - "/usr/local/opt/libnghttp2"); - } - - private static List prefixesFrom(String fromEnvironment, String... defaults) { - List out = new ArrayList(); - if (fromEnvironment != null && fromEnvironment.length() > 0) { - out.add(fromEnvironment); - } - out.addAll(Arrays.asList(defaults)); - return out; - } - - /** - * The codenameone-backend version this module depends on. - * - * Deliberately an error rather than a default when the dependency is absent: - * guessing a version here would translate a different runtime from the one the - * module was compiled and tested against. - */ - private String backendRuntimeVersion() throws MojoFailureException { - java.util.Set artifacts = project.getArtifacts(); - if (artifacts != null) { - for (Artifact artifact : artifacts) { - if ("com.codenameone".equals(artifact.getGroupId()) - && "codenameone-backend".equals(artifact.getArtifactId())) { - return artifact.getVersion(); - } - } - } - throw new MojoFailureException("This module does not depend on " - + "com.codenameone:codenameone-backend, so there is no backend " - + "runtime to translate. Add it as a dependency."); - } - - /** - * The JDK that compiles the sources for translation and runs the translator. - * - * ANY JDK 8 OR NEWER, which for almost every project means the one already - * running Maven and nothing to configure. This goal used to demand a JDK 8 - * and refuse to run without one, on the premise that a newer javac emits - * class files the translator cannot read. The premise was wrong: {@link - * #compile} passes -source 1.8 -target 1.8, so the class file version is 52 - * whichever javac produces it, and the bootclasspath that confines the build - * to the server-safe surface is enforced identically -- a reference to - * java.nio.file still fails to compile on 8, 17, 21 and 25 alike. The cost of - * the premise was paid entirely by the developer, who had to install a JDK - * from 2014 to package a server. - * - * The two properties are tried before the running JDK so an explicit choice - * still wins, and cn1.backend.jdk8 is among them so the setups that were - * required to set it keep working. - * - * WHAT THEY SELECT IS THE TWO FORKED STEPS -- {@link #compile}'s javac and - * {@link #translate}'s java -- and nothing else. The router and entry point - * that {@link #generateControllers} produces are compiled in process by the - * JDK running Maven, which JSR 199 gives no way to redirect. That costs - * nothing today, because every JDK from 8 up emits the class file version 52 - * the translator reads; see {@link #compile} for why it is also not worth - * forking, and why the error message there names Maven's own JDK. - */ - private File resolveJdk() throws MojoFailureException { - String[] configured = {jdkHome, jdk8Home}; - for (int i = 0; i < configured.length; i++) { - if (configured[i] != null && configured[i].length() > 0) { - File home = new File(configured[i]); - if (hasJavac(home)) { - return requireEightOrNewer(home); - } - } - } - File running = new File(System.getProperty("java.home")); - if (hasJavac(running)) { - return requireEightOrNewer(running); - } - // The Java 8 layout points java.home at the jre inside the JDK, where - // there is no compiler; it is one level up. - File parent = running.getParentFile(); - if (parent != null && hasJavac(parent)) { - return requireEightOrNewer(parent); - } - throw new MojoFailureException("Packaging a backend needs a JDK, and " - + System.getProperty("java.home") + " has no javac -- Maven is " - + "running on a JRE. Run it on a JDK, or point " - + "-Dcn1.backend.jdk at one."); - } - - /** - * Refuses a JDK older than 8, whose javac cannot emit the format the - * translator reads. - * - * Only reachable through an explicitly configured JDK: Maven itself needs 8 - * or newer, so the running one always passes. A javac that cannot be asked - * its version is ACCEPTED rather than refused -- the compile that follows - * reports what is actually wrong with it, and inventing a failure here would - * hide that. - */ - private File requireEightOrNewer(File home) throws MojoFailureException { - int major = javacMajor(home); - if (major > 0 && major < 8) { - throw new MojoFailureException("A JDK 8 or newer is required to " - + "package a backend; " + home + " is a JDK " + major + "."); - } - return home; - } - - /** - * The feature version of a JDK's javac, or -1 when it cannot be determined. - * - * Read by running it, not by parsing the path: a directory name says nothing - * reliable. Java 8 prints "javac 1.8.0_402" on stderr and later releases - * print "javac 21.0.2" on stdout, so both streams are read and both spellings - * are understood. - */ - private static int javacMajor(File home) { - try { - ProcessBuilder builder = new ProcessBuilder( - new File(home, "bin/javac").isFile() - ? new File(home, "bin/javac").getAbsolutePath() - : new File(home, "bin/javac.exe").getAbsolutePath(), - "-version"); - builder.redirectErrorStream(true); - Process process = builder.start(); - StringBuilder output = new StringBuilder(); - InputStream in = process.getInputStream(); - byte[] chunk = new byte[512]; - int n; - while ((n = in.read(chunk)) > 0) { - output.append(new String(chunk, 0, n, "UTF-8")); - } - process.waitFor(); - return parseJavacVersion(output.toString()); - } catch (IOException err) { - return -1; - } catch (InterruptedException err) { - Thread.currentThread().interrupt(); - return -1; - } - } - - /** Package private so the parsing is testable without a JDK to run. */ - static int parseJavacVersion(String output) { - if (output == null) { - return -1; - } - int at = output.indexOf("javac "); - if (at < 0) { - return -1; - } - String version = output.substring(at + "javac ".length()).trim(); - // "1.8.0_402" is Java 8; "21.0.2" is Java 21. The leading "1." is the old - // spelling and the number after it is the feature version. - if (version.startsWith("1.")) { - version = version.substring(2); - } - int end = 0; - while (end < version.length() && Character.isDigit(version.charAt(end))) { - end++; - } - if (end == 0) { - return -1; - } - try { - return Integer.parseInt(version.substring(0, end)); - } catch (NumberFormatException err) { - return -1; - } - } - - /** - * What to do about a javac that has dropped -source 8. - * - * Package private so a test can hold the wording to a remedy that WORKS. An - * earlier version of this sent the developer to -Dcn1.backend.jdk, which - * selects the two forked steps and not the in-process compile of the - * generated router and entry point, so following it moved the failure by one - * step and no further. See #compile. - */ - static final String SOURCE_EIGHT_REMOVED_HINT = - "This javac no longer accepts -source 8, which is the format the " - + "translator reads. Run Maven itself on a JDK that still does: " - + "-Dcn1.backend.jdk selects the compiler for this step and the " - + "translator, but the generated router and entry point are compiled " - + "in process by the JDK running Maven."; - - /** - * Whether a failed compile is javac refusing -source 8 outright, rather than - * anything about the sources. - * - * A future release will remove the option -- 21 and 25 already warn that it is - * obsolete -- and javac's own wording ("Source option 8 is no longer - * supported") names no way forward, so the caller adds one. - */ - private static boolean dropsSourceEight(MojoFailureException err) { - String message = err.getMessage(); - return message != null - && message.indexOf("Source option") >= 0 - && message.indexOf("no longer supported") >= 0; - } - - /** Windows names it javac.exe, and a JRE has neither. */ - private static boolean hasJavac(File home) { - return new File(home, "bin/javac").isFile() - || new File(home, "bin/javac.exe").isFile(); - } - - private File resolve(String groupId, String artifactId, String version, String classifier) - throws MojoExecutionException { - Artifact artifact = repositorySystem.createArtifactWithClassifier( - groupId, artifactId, version, "jar", classifier); - ArtifactResolutionRequest request = new ArtifactResolutionRequest(); - request.setArtifact(artifact); - request.setLocalRepository(localRepository); - request.setRemoteRepositories(project.getRemoteArtifactRepositories()); - ArtifactResolutionResult result = repositorySystem.resolve(request); - if (!result.isSuccess() || artifact.getFile() == null) { - throw new MojoExecutionException("Could not resolve " + groupId + ":" - + artifactId + ":" + version + ":" + classifier); - } - return artifact.getFile(); - } - - /** - * Unpacks a jar. Entries under cn1-native/ go to `nativeTarget` when one is - * 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) - throws MojoExecutionException { - unzip(jar, javaTarget, nativeTarget, false); - } - - /** - * @param firstWins leave an entry alone when something is already at its - * destination, which is how a classpath resolves a class two - * entries both carry - */ - private void unzip(File jar, File javaTarget, File nativeTarget, boolean firstWins) - throws MojoExecutionException { - try { - ZipFile zip = new ZipFile(jar); - try { - Enumeration entries = zip.entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - if (entry.isDirectory()) { - continue; - } - String name = entry.getName(); - File destination; - if (name.startsWith("cn1-native/")) { - if (nativeTarget == null) { - continue; - } - destination = resolveInside(nativeTarget, - name.substring("cn1-native/".length()), jar, name); - } else if (name.startsWith("META-INF/")) { - continue; - } else { - destination = resolveInside(javaTarget, name, jar, name); - } - if (firstWins && destination.isFile()) { - continue; - } - mkdirs(destination.getParentFile()); - InputStream in = zip.getInputStream(entry); - try { - copy(in, destination); - } finally { - in.close(); - } - } - } finally { - zip.close(); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not unpack " + jar, err); - } - } - - /** - * The entry's destination, proven to be inside the directory it unpacks into. - * - * An archive entry name is attacker-controlled data, not a path this build - * chose: an entry called `../../../../etc/whatever` makes `new File(root, name)` - * resolve outside `root`, so unpacking writes wherever the entry says. That is - * Zip Slip, and here it would run with the developer's privileges during an - * ordinary `mvn package` against whatever jar the coordinates resolved to. - * - * Compared after canonicalisation rather than on the raw string, because `..` - * is not the only way out -- a symlinked parent resolves elsewhere too, and the - * textual check passes for both. The separator is appended to the root so a - * sibling whose name merely starts with it ("/tmp/outdir-evil" against - * "/tmp/outdir") cannot satisfy the prefix test. - */ - private static File resolveInside(File root, String relative, File jar, String entryName) - throws IOException { - File destination = new File(root, relative); - String prefix = root.getCanonicalPath() + File.separator; - String resolved = destination.getCanonicalPath(); - if (!resolved.startsWith(prefix)) { - throw new IOException("Refusing to unpack " + jar + ": entry \"" + entryName - + "\" resolves to " + resolved + ", outside " + root.getCanonicalPath()); - } - return destination; - } - - private static void copy(InputStream in, File destination) throws IOException { - OutputStream out = new FileOutputStream(destination); - try { - byte[] chunk = new byte[8192]; - int n; - while ((n = in.read(chunk)) > 0) { - out.write(chunk, 0, n); - } - } finally { - out.close(); - } - } - - /** As copyDirectory, but never replacing a file that is already there. */ - private void copyDirectoryFirstWins(File from, File to) throws MojoExecutionException { - copyDirectoryFirstWins(from, to, null); - } - - /** - * @param nativeTarget where a top-level cn1-native subtree belongs, or null - * when this is already below one - * - *

The same routing the jar branch does, and for the same reason: the - * translator has no code that looks for cn1-native inside a class input, and - * only nativeSources is copied into its C source root. Copied as it stood, - * a reactor module's C was staged among the classes where nothing reads it -- - * and a native whose C is absent is not a link error, because a Java native - * method is kept alive BY its symbol appearing in the native sources, so the - * dead-code pass drops the method and the build stays green with the feature - * inert. The same dependency packaged correctly once it was installed as a - * jar and consumed that way, which is the worst shape for this to take. - */ - private void copyDirectoryFirstWins(File from, File to, File nativeTarget) - throws MojoExecutionException { - File[] children = from.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - if (nativeTarget != null && child.isDirectory() - && "cn1-native".equals(child.getName())) { - mkdirs(nativeTarget); - // Without the native target below it: cn1-native is matched at the - // root only, exactly as the jar branch matches the prefix. - copyDirectoryFirstWins(child, nativeTarget); - continue; - } - File destination = new File(to, child.getName()); - if (child.isDirectory()) { - mkdirs(destination); - copyDirectoryFirstWins(child, destination); - continue; - } - if (destination.isFile()) { - continue; - } - try { - InputStream in = new java.io.FileInputStream(child); - try { - copy(in, destination); - } finally { - in.close(); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not copy " + child, err); - } - } - } - - private void copyDirectory(File from, File to) throws MojoExecutionException { - File[] children = from.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - File destination = new File(to, child.getName()); - if (child.isDirectory()) { - mkdirs(destination); - copyDirectory(child, destination); - continue; - } - try { - InputStream in = new java.io.FileInputStream(child); - try { - copy(in, destination); - } finally { - in.close(); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not copy " + child, err); - } - } - } - - private void collectJava(File dir, List out) { - File[] children = dir.listFiles(); - if (children == null) { - return; - } - for (File child : children) { - if (child.isDirectory()) { - collectJava(child, out); - } else if (child.getName().endsWith(".java")) { - out.add(child.getAbsolutePath()); - } - } - } - - private void run(List command, File directory, String what) - throws MojoExecutionException, MojoFailureException { - try { - ProcessBuilder builder = new ProcessBuilder(command); - builder.directory(directory); - builder.redirectErrorStream(true); - Process process = builder.start(); - StringBuilder output = new StringBuilder(); - InputStream in = process.getInputStream(); - byte[] chunk = new byte[8192]; - int n; - while ((n = in.read(chunk)) > 0) { - output.append(new String(chunk, 0, n, "UTF-8")); - } - int status = process.waitFor(); - if (status != 0) { - throw new MojoFailureException("Could not " + what + ":\n" + output); - } - if (output.length() > 0) { - getLog().debug(output.toString()); - } - } catch (IOException err) { - throw new MojoExecutionException("Could not " + what, err); - } catch (InterruptedException err) { - Thread.currentThread().interrupt(); - throw new MojoExecutionException("Interrupted while trying to " + what, err); - } - } - - /** - * Removes each directory and its contents, and refuses to continue if one - * survives. - * - * The emptiness is the point, and it used to be assumed: File.delete returns - * false for a locked file on Windows or anything under a read-only directory, - * nothing looked at that, and the stale .class stayed where ClassScanner, - * requireMainClass and the translator would all find it. A controller or an - * entry point deleted from the source tree is then still packaged, so the - * 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 MojoExecutionException { - for (File dir : dirs) { - deleteTree(dir); - if (dir == null || !dir.exists()) { - continue; - } - String[] left = dir.list(); - if (left != null && left.length > 0) { - throw new MojoExecutionException("Could not empty " + dir - + ": " + left.length + " entr" + (left.length == 1 ? "y" : "ies") - + " could not be deleted, and building over them would package " - + "classes that are no longer in the source tree."); - } - } - } - - private static void deleteTree(File file) { - if (file == null || !file.exists()) { - return; - } - File[] children = file.listFiles(); - if (children != null) { - for (File child : children) { - deleteTree(child); - } - } - // The result is checked by emptyDirs, which looks at what actually - // survived rather than at each delete: a directory that could not be - // removed but is empty is harmless, and one that still holds a class is - // not, whatever the reason. - file.delete(); - } - - private static void mkdirs(File... dirs) { - for (File dir : dirs) { - if (dir != null && !dir.isDirectory()) { - dir.mkdirs(); - } - } - } - - private static String join(List parts, String separator) { - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < parts.size(); iter++) { - if (iter > 0) { - out.append(separator); - } - out.append(parts.get(iter)); - } - return out.toString(); - } +public class BackendPackageMojo extends AbstractBackendNativeMojo { } 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..f4a626099d8 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendTestMojo.java @@ -0,0 +1,303 @@ +/* + * 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.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 java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +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: `mvn test + * -Dcn1.backend.compiledTests=true`. + * + * The same test classes Surefire runs on the JVM, translated with the application + * by ParparVM and linked the way {@link BackendPackageMojo} links a server -- 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 {@code @MockitoBean} is reported as skipped. + * {@code -Dcn1.backend.compiledTests.strict=true} turns either into a failure. + * + * 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. + * + * The results land in target/surefire-reports as {@code TEST--compiled.xml}, + * beside Surefire's own, so a CI report shows both runs. + */ +@Mojo(name = "backend-test", defaultPhase = LifecyclePhase.TEST, + requiresDependencyResolution = ResolutionScope.TEST) +public class BackendTestMojo extends AbstractBackendNativeMojo { + @Parameter(property = "cn1.backend.compiledTests", defaultValue = "false") + private boolean enabled; + + @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; + + /** Test sources left out of the compile, and why. */ + private final Map excluded = new LinkedHashMap(); + + @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; + } + if (testSources().isEmpty()) { + getLog().info("cn1: no test sources, so no compiled backend tests"); + return; + } + super.execute(); + } + + @Override + void prepareSources(File work, String runtimeVersion) throws MojoExecutionException { + 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); + } + + @Override + void afterGenerate(File jdk, File javaApi, File classes, File work) + throws MojoExecutionException, MojoFailureException { + File testClasses = new File(work, "test-classes"); + emptyDirs(testClasses); + mkdirs(testClasses); + List sources = new ArrayList(); + for (File f : testSources()) { + String text; + try { + text = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } catch (IOException err) { + throw new MojoExecutionException("Could not read " + f, err); + } + if (text.indexOf("org.mockito") >= 0) { + excluded.put(f.getName(), "imports Mockito, which runs only on the JVM"); + continue; + } + sources.add(f.getAbsolutePath()); + } + if (!excluded.isEmpty()) { + String list = excluded.toString(); + if (strict) { + throw new MojoFailureException("These tests cannot be compiled: " + list); + } + getLog().warn("cn1: left out of the compiled test run: " + list); + } + if (sources.isEmpty()) { + throw new MojoFailureException("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), + "-d", testClasses.getAbsolutePath())); + command.addAll(sources); + run(command, project.getBasedir(), "compile the tests against the backend's class " + + "library (a test that uses a JDK class the server runtime lacks fails here)"); + // The tests' resources, as Maven processed them. + File processed = new File(project.getBuild().getTestOutputDirectory()); + if (processed.isDirectory()) { + copyResources(processed, testClasses); + } + List roots = new ArrayList(project.getCompileSourceRoots()); + roots.addAll(project.getTestCompileSourceRoots()); + List cp = new ArrayList(classpath); + cp.add(javaApi.getAbsolutePath()); + BackendTests.process(classes, testClasses, new File(work, "test-stubs"), + project.getBasedir(), roots, sourceEncoding(), cp, true, 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, + com.codename1.maven.processors.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 MojoExecutionException("Could not remove " + entryClass + + " from the test translation"); + } + } catch (IOException err) { + throw new MojoExecutionException("Could not read " + marker, err); + } + } + mainClass = BackendTests.MAIN_CLASS; + } + + @Override + File binaryFile() { + File dir = new File(project.getBuild().getDirectory(), "cn1-backend-test"); + mkdirs(dir); + return new File(dir, project.getArtifactId() + "-tests"); + } + + @Override + void afterLink(File binary) throws MojoExecutionException, MojoFailureException { + File log = new File(binary.getParentFile(), "test-output.txt"); + int status; + try { + ProcessBuilder builder = new ProcessBuilder(binary.getAbsolutePath()); + builder.directory(project.getBasedir()); + builder.redirectErrorStream(true); + builder.redirectOutput(log); + Process process = builder.start(); + if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new MojoFailureException("The compiled tests did not finish in " + + timeoutSeconds + " seconds; their output is in " + log); + } + status = process.exitValue(); + } catch (IOException err) { + throw new MojoExecutionException("Could not run " + binary, err); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted while running the compiled tests", err); + } + List lines; + try { + lines = Files.readAllLines(log.toPath(), StandardCharsets.UTF_8); + } catch (IOException err) { + throw new MojoExecutionException("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(project.getBuild().getDirectory(), "surefire-reports"); + try { + report.write(reports); + } catch (IOException err) { + throw new MojoExecutionException("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 MojoFailureException(report.skipped + " compiled test(s) were skipped and " + + "cn1.backend.compiledTests.strict is set:\n" + report.skips()); + } + if (!report.done) { + throw new MojoFailureException("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 MojoFailureException(report.failed + " compiled test(s) failed:\n" + + report.failures()); + } + } + + private List testSources() { + List out = new ArrayList(); + for (Object root : project.getTestCompileSourceRoots()) { + List found = new ArrayList(); + collectJava(new File(String.valueOf(root)), found); + for (String f : found) { + out.add(new File(f)); + } + } + return out; + } + + private static void copyResources(File from, File to) throws MojoExecutionException { + 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(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (IOException err) { + throw new MojoExecutionException("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/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompiledTestReport.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompiledTestReport.java new file mode 100644 index 00000000000..d348d700cf2 --- /dev/null +++ b/maven/codenameone-maven-plugin/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/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..e42ee886b5b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessTestAnnotationsMojo.java @@ -0,0 +1,89 @@ +/* + * 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 = BackendTests.process(outputDirectory, testOutputDirectory, + new File(project.getBuild().getDirectory(), "generated-test-sources/cn1"), + project.getBasedir(), roots, encoding, classpath, false, getLog()); + 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/processors/BackendBeans.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java index 38891479cbd..e97667a6893 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendBeans.java +++ b/maven/codenameone-maven-plugin/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,154 @@ 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. + int weaveTests(File out) throws ProcessingException { + int woven = 0; + try { + for (BackendWeaver.Plan plan : plans.values()) { + if (BackendWeaver.weave(out, 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 +659,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 +772,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 +969,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 +1460,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 +1722,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 +1919,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 +2152,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 +2211,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 +2309,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 +2772,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 +2785,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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java index d4ecbcb57f5..e48456d1908 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendJsonCodecs.java +++ b/maven/codenameone-maven-plugin/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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java index 0fd7fd709ba..40b7607d331 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSettings.java +++ b/maven/codenameone-maven-plugin/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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java index 5e4c2c79dd9..dba933b0d0a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendSources.java +++ b/maven/codenameone-maven-plugin/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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java new file mode 100644 index 00000000000..b512f5bc2d4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java @@ -0,0 +1,787 @@ +/* + * 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(); + + /// 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) { + this.ctx = ctx; + this.mainClasses = mainClasses; + this.testClasses = testClasses; + this.mainOut = mainOut; + this.compiled = compiled; + } + + /// 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 { + BackendTestGenerator g = new BackendTestGenerator(ctx, mainClasses, testClasses, mainOut, + compiled); + 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.getClassAnnotation(BACKEND_TEST) != null && !cls.isAbstract()) { + 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) { + sources.put(contextBinary(spec.test.getBinaryName()), 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 && runner(cls, byTest.get(name))) { + count++; + } + } + 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; + AnnotationValues a = cls.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. + String prefix = cls.getInternalName() + "$"; + for (String name : testClasses) { + AnnotatedClass nested = ctx.lookup(name); + if (name.startsWith(prefix) && nested != null + && nested.getClassAnnotation(TEST_CONFIGURATION) != null) { + 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 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()); + 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 `_`. + 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"); + 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; + } + 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 = hierarchy(cls); + List tests = new ArrayList(); + Set seen = new LinkedHashSet(); + for (AnnotatedClass c : chain) { + for (MethodInfo m : c.getMethods()) { + if (m.getAnnotation(JUNIT_TEST) != null && seen.add(m.getName() + m.getDescriptor())) { + tests.add(m); + } + } + } + if (tests.isEmpty()) { + return false; + } + String testType = cls.getSourceName(); + String binary = cls.getBinaryName(); + String runnerBinary = binary.replace('$', '_') + "Cn1TestRunner"; + int dot = runnerBinary.lastIndexOf('.'); + String pkg = dot < 0 ? "" : runnerBinary.substring(0, dot); + String simple = runnerBinary.substring(dot + 1); + String skip = null; + if (cls.getClassAnnotation(JUNIT_DISABLED) != null) { + skip = "disabled: " + cls.getClassAnnotation(JUNIT_DISABLED).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); + List afterAll = lifecycle(chain, JUNIT_AFTER_ALL, true, false); + sb.append(" boolean ready = true;\n"); + for (MethodInfo m : beforeAll) { + sb.append(" if (ready) {\n try {\n ").append(testType) + .append('.').append(m.getName()).append("();\n") + .append(" } catch (Throwable err) {\n") + .append(" com.codename1.impl.backend.test.TestRun.lifecycleFailed(CLS, ") + .append(quote("@BeforeAll " + m.getName())).append(", err);\n") + .append(" ready = false;\n }\n }\n"); + } + int index = 0; + for (MethodInfo m : tests) { + AnnotationValues disabled = m.getAnnotation(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(", \"@BeforeAll failed\");\n }\n"); + } + index++; + } + for (MethodInfo m : afterAll) { + sb.append(" try {\n ").append(testType).append('.').append(m.getName()) + .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); + List afterEach = lifecycle(chain, JUNIT_AFTER_EACH, false, false); + 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(" test.").append(b.getName()).append("();\n"); + } + sb.append(" test.").append(m.getName()).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 test.").append(a.getName()) + .append("();\n } catch (Throwable err) {\n") + .append(" if (failure == null) {\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) { + List out = new ArrayList(); + Set seen = new LinkedHashSet(); + for (AnnotatedClass c : chain) { + List level = new ArrayList(); + for (MethodInfo m : c.getMethods()) { + if (m.getAnnotation(annotation) != null && seen.add(m.getName() + m.getDescriptor())) { + 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; + } + + /// 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."); + } + } + + 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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTests.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTests.java new file mode 100644 index 00000000000..7c3edc882a6 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendTests.java @@ -0,0 +1,102 @@ +/* + * 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 org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.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 MojoExecutionException, MojoFailureException { + Map main; + Map tests; + try { + main = mainClasses.isDirectory() ? ClassScanner.scan(mainClasses) + : new LinkedHashMap(); + tests = ClassScanner.scan(testClasses); + } catch (ProcessingException err) { + throw new MojoExecutionException("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); + } catch (ProcessingException err) { + throw new MojoExecutionException(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 MojoFailureException(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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java index a0b088995dc..2c66ed07bf5 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BackendWiringWriter.java +++ b/maven/codenameone-maven-plugin/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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java index b6072fddd2c..e5c4a55dfe1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/CronCompiler.java +++ b/maven/codenameone-maven-plugin/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/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java index eecba159a27..0bd8062ed2a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestControllerAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/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,53 @@ 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 a malformed multipart body before any part is bound, 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; + for (int i = 0; i < route.params.size(); i++) { + Param p = route.params.get(i); + 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 +1738,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 +1798,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 +2284,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 +2357,28 @@ 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 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 +2624,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 +2775,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 +2799,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 +3040,66 @@ 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) { + File f = applicationProperties(ctx); + if (f == null) { + return null; + } + java.util.Properties props = new java.util.Properties(); + InputStream in = null; + try { + in = new java.io.FileInputStream(f); + 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 module's `application-.properties`, loaded, or null when it + /// has none. + static java.util.Properties profileApplicationProperties(ProcessorContext ctx, + String profile) { + File base = applicationProperties(ctx); + File dir = base == null ? null : base.getParentFile(); + if (dir == null || profile == null || profile.length() == 0) { + return null; + } + File f = new File(dir, "application-" + profile + ".properties"); + if (!f.isFile()) { + return null; + } + java.util.Properties props = new java.util.Properties(); + InputStream in = null; + try { + in = new java.io.FileInputStream(f); + 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 diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageEncodingTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageEncodingTest.java index 2056d2e03b2..11c85cfc045 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageEncodingTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageEncodingTest.java @@ -104,10 +104,10 @@ private static MavenProject project(String property, String pluginEncoding) { private static String encodingOf(MavenProject project) throws Exception { BackendPackageMojo mojo = new BackendPackageMojo(); - Field field = BackendPackageMojo.class.getDeclaredField("project"); + Field field = AbstractBackendNativeMojo.class.getDeclaredField("project"); field.setAccessible(true); field.set(mojo, project); - Method method = BackendPackageMojo.class.getDeclaredMethod("sourceEncoding"); + Method method = AbstractBackendNativeMojo.class.getDeclaredMethod("sourceEncoding"); method.setAccessible(true); return (String) method.invoke(mojo); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJarDependencyTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJarDependencyTest.java index b4a10e0c492..a6ceed6e1fc 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJarDependencyTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJarDependencyTest.java @@ -210,7 +210,7 @@ void addsNoInputWhenThereAreNoDependencies(@TempDir File tmp) throws Exception { private static List stage(List classpath, File staged, File natives) throws Exception { BackendPackageMojo mojo = new BackendPackageMojo(); - Method stageClasses = BackendPackageMojo.class.getDeclaredMethod( + Method stageClasses = AbstractBackendNativeMojo.class.getDeclaredMethod( "stageDependencyClasses", List.class, File.class, File.class); stageClasses.setAccessible(true); Object out = stageClasses.invoke(mojo, classpath, staged, natives); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJdkFallbackTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJdkFallbackTest.java index 3be3196d6c8..6a024f64bb0 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJdkFallbackTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/BackendPackageJdkFallbackTest.java @@ -47,14 +47,14 @@ class BackendPackageJdkFallbackTest { private static File resolve(BackendPackageMojo mojo) throws Exception { - Method resolve = BackendPackageMojo.class.getDeclaredMethod("resolveJdk"); + Method resolve = AbstractBackendNativeMojo.class.getDeclaredMethod("resolveJdk"); resolve.setAccessible(true); return (File) resolve.invoke(mojo); } private static void set(BackendPackageMojo mojo, String field, String value) throws Exception { - Field declared = BackendPackageMojo.class.getDeclaredField(field); + Field declared = AbstractBackendNativeMojo.class.getDeclaredField(field); declared.setAccessible(true); declared.set(mojo, value); } @@ -131,7 +131,7 @@ void readsBothSpellingsOfAJavacVersion() { @Test void refusesOnlyBelowEight() throws Exception { BackendPackageMojo mojo = new BackendPackageMojo(); - Method require = BackendPackageMojo.class.getDeclaredMethod( + Method require = AbstractBackendNativeMojo.class.getDeclaredMethod( "requireEightOrNewer", File.class); require.setAccessible(true); @@ -171,7 +171,7 @@ void theSourceEightRemedyIsTheJdkRunningMaven() { /// a JDK problem. @Test void recognisesOnlyTheSourceEightRemoval() throws Exception { - Method drops = BackendPackageMojo.class.getDeclaredMethod( + Method drops = AbstractBackendNativeMojo.class.getDeclaredMethod( "dropsSourceEight", MojoFailureException.class); drops.setAccessible(true); assertEquals(Boolean.TRUE, drops.invoke(null, new MojoFailureException( diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java index b517b96192d..910efd26ad2 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendBeansTest.java +++ b/maven/codenameone-maven-plugin/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; @@ -80,7 +83,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 +93,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 +113,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 +125,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 +179,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 +277,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 +428,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 +452,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 +468,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 +504,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 +536,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 +581,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 +604,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 +654,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 +717,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 +750,29 @@ 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 /.")); - } - - @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))); + // 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 @@ -883,7 +847,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 +859,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 +932,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 +968,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 +1019,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 +1091,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 +1113,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 +1195,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 +1234,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 +1242,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 +1284,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 +1293,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 +1323,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 +1347,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 +1490,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 +1507,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 +1568,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 +1582,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 +1616,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 +1639,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 +1678,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 +1717,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 +1774,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 +1793,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 +1828,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 +1858,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 +1875,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 +2040,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 +2068,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 +2099,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 +2109,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 +2143,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 +2180,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 +2244,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 +2281,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 +2321,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 +2409,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 +2429,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 +2501,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 +2611,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/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java new file mode 100644 index 00000000000..ebf0b3a180d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java @@ -0,0 +1,248 @@ +/* + * 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 org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.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.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 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 (MojoFailureException 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)")); + } + } + + 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/integration-tests/cn1app-archetype-test.sh b/maven/integration-tests/cn1app-archetype-test.sh index f269d41b126..d449a9e2077 100644 --- a/maven/integration-tests/cn1app-archetype-test.sh +++ b/maven/integration-tests/cn1app-archetype-test.sh @@ -73,6 +73,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. # @@ -136,7 +156,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 @@ -151,4 +171,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/pom.xml b/maven/pom.xml index 35acbefc031..ef3740aa7f2 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -92,6 +92,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..ac076117112 --- /dev/null +++ b/scripts/hellocodenameone/backend/server.sh @@ -0,0 +1,206 @@ +#!/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. +install_runtime() { + if [ "${CN1SS_BACKEND_SKIP_INSTALL:-0}" = "1" ]; then + return 0 + fi + local digest stamp + 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 \ + -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-$digest" + if [ -f "$stamp" ]; then + return 0 + fi + log "installing codenameone-backend, codenameone-backend-test 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 backend,backend-test,codenameone-maven-plugin >&2; then + log "the plugin's dependencies are not installed; building them too (-am)" + JAVA_HOME="$(install_java_home)" "$(mvn_cmd)" "${install[@]}" \ + -pl backend,backend-test,codenameone-maven-plugin -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 + 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..97d1c97d4d9 --- /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"}; + 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..a41243edfa7 --- /dev/null +++ b/scripts/hellocodenameone/backend/src/test/java/com/codenameone/examples/hellocodenameone/backend/ProbeApiTest.java @@ -0,0 +1,139 @@ +/* + * 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.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 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/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..736dccc251c 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,10 @@ */ package com.codenameone.examples.hellocodenameone.tests; +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; @@ -525,6 +529,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..520ab7cc92d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendClientTest.java @@ -0,0 +1,143 @@ +/* + * 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. +public abstract class BackendClientTest extends BaseTest { + private final List steps = new ArrayList(); + private int current = -1; + + /// 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) { + done(); + return; + } + try { + next.run(); + } catch (Throwable err) { + fail("step " + (current + 1) + " threw " + err); + } + } + + /// Fails the test with `message` unless `condition` holds; true when it does. + protected final boolean expect(boolean condition, String message) { + if (!condition) { + fail(getClass().getName() + " step " + (currentStep() + 1) + ": " + 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..0ada38e4486 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendConnectionRequestTest.java @@ -0,0 +1,237 @@ +/* + * 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. + abstract static class Probe extends ConnectionRequest { + 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); + setFailSilently(true); + 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(Exception err) { + 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) { + throw new IllegalStateException("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..c65a0cf6af9 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/backend/BackendTransferTest.java @@ -0,0 +1,176 @@ +/* + * 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(Exception err) { + 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/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/resources/agent-skill-agents-md.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md index 7d5b732c3e4..7756a1b3716 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 @@ -24,7 +24,7 @@ their own conventions; the canonical source of truth is `.agent-skills/`. attach a Java debugger to the device build and drive it over MCP the same way; see `.agent-skills/codename-one/references/on-device-debugging.md`. - Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`. -- The server side lives in `backend/` (Spring-style `@RestController` / `@Service`, +- The server side lives in `backend/` (Spring-style `@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. 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 0e383520c0b..d8c9c3e131f 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, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `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, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `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/common.zip b/scripts/initializr/common/src/main/resources/common.zip index eec64cf9c490619557fdba163e7a97c4bd39425d..9dfbdcdfa4d6cd6008e245f3a4b8596afd008d01 100644 GIT binary patch delta 9298 zcmaKR1z1(v*7jyEy1P?q(=91TmxP2!O3S88L_jy7fVAY2ZjkPf6p-%jQo2h*!XMB1 z&N=tH_kRD{&wAFFbIcKQj9JoCl z>=!8Xo#yUeG`08(p`Mym#%6QeCoc&#X&A1OkMZ#4A!K{KuB1SapJkJnB{HpZ4ZTOv&QbE}D;XAh(Me)f_y zfEyz;jvL$T+60Wgo6GYvfR|l(U6(^MehlUetcLQysdZ(~eGRVpk=9nT+o7WdqK8F? zA6;|!c+gGR#2%j=r@+c;_>Ft8jgiZBa~h}(>#zs2W*Fb)#=$VIzQv}_)WTEeDbjO@ z)oz}Pa>&I+ah2FARM-aXcqP_qpkk}4k<}rFovTWz^(+$=Yf-zYJWrSQK2Bd3M?CfRa zH-I=`3ZdXa*ONg=aR-0;#ES$<6ZCOMr7pdGhAw|Y?kF8rj7n| z){V+#!AUuIHQEFB4=HwNJbJOA@3(mpkTjfVvisC{?bIluzDG!>TDQzLHWI_` z#a8p0=;aL`j?pJ}x+`SAEXu+j-mhfwy|ukG+vZ6c2teiDVUC+pJ4E& zcL-hJTlZ9cMC={1!iM||x@7Xg)<6e2I%His3)9+|i>d_PQuS(cl1}QH3T3A@`|D?) zozNF)(Fz~%`H#h;C#RpjRy>|9(Bg%a|8$fAvf1fJpWEgM`kAb%j}$ijLRUjo62j!S zWoAF-ibNTD)_!n)cm=SQhFO*(iY{F30@F$v|0ET|P%*NKcIPlu+Zwwv-8q0uUj4pYW@vH}QopCY1D)N}V0LSAYos7X zEL>go5tDdm>+Zg)qnLz>Ufg8LV@ssL~8 z$?((y%9Oe0$(NSV5osm-6C&dwze0Ngi4PV=Z&GX1ImwIFP6a2@V5`=Qt9o+C9W%({ zaozimY%C_9!$gSOs+8SFm7tx^i&$fn%2AG#AhU|8LXT`=@j z8|$8MZiKvl;4T`2v|ghuqFGAx_0=&BA(rR3Ci_Frt`XS5R=9exj%gu=7&#Ixet@~J zAu~RbmBWwd#Mp!mSLK$Mj|i);op-O7?@usUX5bAAO~P4ERP_*o9JOf=W_|B-L3aW^ z=>E)Umnq@1>)tIVBTEc=&=p=GOXsDf5$4$(MyTD9jGE_vpMLDgvHoQsAT(9KFbyVY z%_?E!nl%*3GTw>{;p!w$3h{L$@oW+%m-q}}AVG2{&C`>Btcv`Cg-ya^2J={FBr;gM zKkOD6N_mgyQxf8OOS6`{>0QkwepOW>)!S{pAKSGOx}^TB6-&hmOrbMLJ+V`3A^H9H zSS?mAE@r-bTw@5Txyjft{&f^c)%C$zctOGo)8r+9@g7j!`(ijm4!F55(ukaR#`MKN z`E}HHBOIlQBsCd=DHcm&<(|P}lHR#rNmu5?&>OdN>qsDUtg4guX9(lhjn&HdC=Tga z%I=!TznfoRPBnOz$U{4+1Izh~U-4x-4{NV_%5KC1Tpkg?t;tbvV6q1R9!(en12r{Y zaK?zpUtaf{>#-V5q37xfyl(9evTfYaM3H3>kWX0Kva4RVxz2U!+1Nu~yDgV%z9#@C z1cpu^KJyET>EsVeR3`=;_4OCtp6iW84HzL|r6wKU{&clxQA91K>J((L6-Z^#X;_$) zQzMVF$#cL~(6B!?A(kJSipWFnLK&0KGKB1OJoQFIHQspcH1592jhZ6d@7kH@L2so; z;i7}l0JYxg-4D-8s`GE^Z=H>kjM~YIiB~5S_rq1+w48f5IW4X9EI6Pk4{csk(li?a zd6ssP2CZL((WF(_Th>yan6#gM7W%dOgFY&X@Jv`s1l{X(czf%0QteIT*6s)*rkRE^ zMvsgVK{U{cs(iPRGm=0ccz64`xtWEXm7^uk|Lp74PY>L_*AU$7w?HS{S@yz|Qrtd2 zD(LOK>CRSL_ffZF9gFl+Qfi|>ehR57^7z?uzUQBi=cmd}*#0LbkN1 zr!db(S5-+Cb8R64ONaSxGJzdX<-K(U^4=9}q!I`q6!WsxnZ z&w$i0PP`@I#{Grp6+1%ViRb#YYKWimC8!Z8^9r>ujN3spPtQc>;BHB9} za&I$?NK1Mrnu=_*j%|5%y!duZa)L({o8slaLRxAov_fZ01=Z}do8>wycV3phslUCN z7%=w`c9s&qKxCfBG*`xPI>vEDT6oGJE3FpK`(pH+k7`RSe5Yt){+>5~C^B>*8-HZ& zRHK5tW@dnF%)T;t&+!HRQ!$d+ZwRD=auss8aQHlo0l@Pjxi>J$C#4$ip-zt$S7n7C z7e>HR+gtd`E5*pKu=?1AXSq#`{8O;vX#b2gRUTwM+)wp&9u$QEzF{Pe6i z(0maR{W5h*p|kpALjJs>Y~$qYWka&V{rj5sgSkGp7k$hd0-xF#q|>%ws-A^@?Ydbw zgggQA@I?l2R;7h(Bv@;rb8pD2ZfX_~OEKk~NZ~Ij`C@IBTzC>sK+J4aXpbrX1gJw% z8gF7xgy~%?AFvzM<*>jX{!lIkqtrB{307W>(9&)_i*$V z!)>{mwd^d?h1_jImO_AUfG?T4Pj*~?xPK>GO+;wyalE`HZj+XYg4J23`}9CVArU&abOT=v3xLE!R?@kuW~m5g-Agvq#C*eb59E_-;D z+WpV2ALkG3*^~2_bJ)X9tCT{(Uve|ee2L2}5Q&dcQpBQAJzBBYeuvn##sF?#o)6bm z_pLHC_)RqZE#K8&QHLTb!*tm@{Bv`3Z91yyW_b+HB8c66JEOcJh1}oI0_3@4MIsB7_UnP^k$7-@EYbpr;pG^@(u9rIQ7Y|PzbNNE zR}9NRGJ3c=N%9&0mSt)$B0WUTH_$+4DG~g7K(7{O-q{?LU-+7BRlfZOI zuY}GwAh+p|1y>zl|EZc0Gwa^7U-K1}FpoA5U!C-_>DVAqafayD!BoooSxJbZ{)xn| z?Vamnb$Z5TQw10Tf2U%ise|)@Wbh-NXRLKjn!(yGTOo6O4gN%mj5=w;1^14E2#u$V zYD-f>VdY9(WcRYKPfbhwSa}w!+zIvKL&_cS`~wZ9ZTPgX%r1|fhhDdIcNDq~m&4ph z{6|Ix=6>npCcMD+Q2Z7_YnP7u&?YC{_Hk5=oF<#xMRn%1IsbHHh&XFSBLYKtZts|C=JIi2DmzkAj9!;mZFle`(Pq`*t zpiU4TK1!g{b4;IP>~~l*N`P&M)7M(je&}jxpAlu_omw4~{m1|&@NBWoJhv~aM@U`5 zc4ojqIA{(9FEYQV`-y5 z9(u({g*7PBp2w**8X5Ts`3&jTlbq5R{cZRqdXO|_JZns!Q>SRs>km~OyG)K6@-Zq` z!qUB1l2m%VDTR7KKpI=c_%0{VaGW5A~^!sGQqUSNZ52#l_=#8E7lNk3E8V8^2s$saCXB_AyxU>JHoS6bLKD~mS$b>F#U(vBMC;>|l%CgchS zpH~|(gcRJFPQ+MCE7l0YYa5`jz7uj{uwzaz6AMNmXg`rQUU(5z?sUfPX zw`rNZuov4nlFyr8-rnb&`huG0+u99@ikYK$JNh8NT?IbGM6UMCCI0y@G)+|infPwE zg~W8x5WFR#2_m9Fy1SvZL^MD~Iu<&F-ciHoHy;YNGy#-Q0P=go2Gb7!2of8qXOaAf3AEpl-<$LXxqep{~e)J{x_D&?H-Nx8ORZi0nY z3FXg+Eb>}A)#VDz5deEpP9wM0a`#Gbq2Kb2WLAT|a)+9z$uPoRI`n97_vRz_^OK;C zQ!lOF-BZ`b*w3!~#-kIb){kObj;=hTka}n3MmK#An{fMyZ^ZU=ma%59;;USz%lY|G zDs72+N6Wj4OZ&Ydky_!#9e!E^u_E@2moIMJUcc0s+7*GRo_JV==Tc?O$mg=+j(93= ztfnlSE6caSvtfuyqY>LQf|BgKwG7t!<7p-4BC6r3ue#I&ofw8st;IM=qDP8#{k|~O zg%i(BuucX?4=uBTPdcVQ>7?N!Cg;&B#)Vp?5<)oqq!}yI1tDR5g^ph~7hLE4at-_l zif?Kj52hcuTk2fg1b!o9DP%O-O9HU#7ARm%=C+KbhI0s3ozWn;Xu<;MuRH%>ILN#l zw%;3sMVvRA<(pRbmVd5O1#g)h2A@A; zw5;bwtvvnm#$3`cLi!;3Qc93Gos42?hDbVFsdrkblsIBxcWt^8KKF#@`E8(#-%mx- zXLN8MUn&G+gdIo01W|ijBF;V%jmPCu1W}or)?|a4b=jQVM*DY^1R>G1pH*0>CQ@~) zOPteW+4*B{C2#m;;#?z1Hw?{h;`D)wUN5?zr-NPZZ0or;cYOrK#A$Ba?`K|Fxk${; zSI8h{8?sFYOMOzdm*>>fUZ@PrtvJ`t;hdT+igzLo_B87|jY*igWR!F%w(zjY(R6D* z8=@}}AVV z;{w_Gcb+yq%$$_XJ!l+C6_}?j0Z~~l)QtKqek=)|8*r_7H@*OZQM;_2{yJWQ#_IRP zN|gw|{{6N4d}Y{1W%o-$zTK4N9kg8i*a+P~{i(0^PBZCsbxcDC=JdxKY5O&248xQ! z@9{~m2;nK~d4?6G)gNYuKnz@$4D zH&fLtU*!xaPjAgLd1#?!iXhX~za#ij&q#T`Rc0YMxU?1itqHXiy``?#TspVbCT593 z0@XCNxGX+siDS;XV{((xk_TOLHFo%0dBNAc_bPPuY(cxc-xuMt{I0tc&iMqtiaR6` zY1iy3#mj5?h6}NabKjx;1ol{b@0g8J2c3p;cZ7^*ZC+_@QYJ9W-Oe!jIMdVFme_;(uo>!Q3P{z52jt`DOx0)%*DRDm-LlEm|~Ss zxL{Ta#f(tM^;I+@hw_MHQsz#O9N97UD_-lRV@U;Xu%uP`!i^YB8NenLX@o zo|{~2tLx2w;t-b8Gw!83E@xa-9}oH9~ee z^F^6o5`^?Sq#8OE7)@)CE?V=6;b&`LSXPrXFp z4_|Y?Cty?4$0&w5F7fwXzfm}1j6vvQfl1pC+4+59U9SL5aiggYNH^i4+F0METJO`N zA6$*0F-*4QzaJ#Zd>MQZPRT!PK{1uV$_ch%>56P(s5H#F%_3C#G6!kh zZo^~(f8GndiSIPrR#4m`rC2o&9gia<89<^|f927|H;wVWZBxy(Ow{iA?gIHZWz`Wd ztuNp7W;6PiZ7UL^T6Gxe;nn-1cg-BI7Us)mpM;Q}5qC}_B~xznP+Id*r-dnW@hG;? zMlM@Z)aG+x5)-Eh&SKdPYWzgZZcL6RQM(=#>b0*p)`qUGG>s@tt$oPK)wf~_f20)8 z77;L$#jMG-9P1p{aDfU zK7<)W0o$B}iJOEtT1MWL+DxS1ORt9C|J{OL3tY%zhH|-<%IAcJm9BvTu zU`L28uTNKAQOhzcPjE*fTxiF(G9F#0uHl2*IO?F65lRTmJvw$6{Shh`BzQ1@z{%># zuwyplM&yIy^-p5Bj?KnH9`s-c;rhwt?m>ryvRM$v!zSIy&1i!R4aCR}CnrnDq$cf( z9ObB{UQUW))DbPy+8T<+3yepWfh~||F`ZA?Bh*UYpM9P>eob!$5Jcol+V}VpSWCYD~>w{$>$G^yVa8U?twmp-;LR zg-f5AvK`W@XPCu5{UO8~yziR0E`mejPZ6Vrl{tl{)!@ea>H^;e*ZyhzH_HT*!k97WLCf2rc^{}%1 z*L)oQ#LQ_{3IArWlzgo(Dnx)GPenH?W}79Ko|&9tBh#mV)Vp==qw&j^;X8#tmkz>l z)J9GAZ84sDKVR%`d?jvuZTqlvb5y5Tcdl)WS3KvWvgZuhlm!FQhXu?Kj+6IKVZ+5PuB8GQNZZHU>;Gs){_H|jwKTiA9EVTTf&|+ zf`jWEiG!t#iSjGDvF;Ff2DMeU?_oo^REmW80__Tb0v(`>?H@Cw=4V+Gu{Ub2j;uDOSt7H~$ zyJ06+2%?V2N9&4pZ8~V=&T}w#FRpwKS8b5dpLtJF~ zd3V!p#&`+4D4MfXz!_KV3X2N&^D?}pq0)`52NZn|$6nRHe=-cbBN*j>L?1J^!<(_@ zP!Xm_uxYG(DfPfIz3aZCnA%gv0I~viL1I{gE#k9%PG!X)o!xElT`txm)rb^alnuvS zix(v^=gH`O#GVX@$z57?@J)*bk8V12nbH;Vovrwf2UZ<1+7|gw;<3L{_GeT`+~Fi2 z*}*7!_XhTO3G-IF8tJe~urp$_$%S^(ooICC($e+pYBjPFW1*zY0^X;Exe8M3p?tl3 zgqUhB%UvYZ@qE@q6Pc8YQPJK!Lb$W=v9j|n6LTeekkCt}BiE!fo_R5odGx?VruOu6 zd<31Cx|zf$xik8j<4W%4=a^Puu6s6M_Z88)MOuY&TL zP?4(Zhb7O)K-~6P9zFNIO*bwTCL!_`F=~%Tn)}AW*<42=emWzVsB}mC9AdA}9jso{ zxci<>6~q`lB+4Jup@U+6JDe>PVwU~eHHE!lrE8iCt1SJd8OWW#`_6D z&Ijtk4VYZI2`W)b$p;%5eeq1ps1-1&>9^;170};_;hts6k-So^nKanqlFjTFe(p2~ z+dyW(m{mbY?1bBR$2ED6o|-XEMJ;mJo#Mugrkz>})OpuXn9dt_2-ATs(W$unv@_)D z*SOdm9cQO69@Z_s)-!!+s9&vkvZaU+;uV-b7z_|@m{^1*Q}0n?2b1giM+BTokObz@ ztc7Q7UU6jNbx&ziU~BZZ_k3!{d-!F1y3Tt6TcZ<&X*sl|yrJ!SYA&7Q`|0U7l=K8n zRe?-iG9HK{U+nb-N8ztIyibf`W-Vl`ba>jnEogLkM|_+0Q(AIRb05G2G1dUWm~m$hy*cOm_y4rvvj2c5S7T4-7q_+bl>hK_cDV_N_(+UeaOxRV>` zvH>uGWnTbf5b^zP3SW9b$!$OoZN2wTw&NN=3OXMH_+Z-hol^vTVCgm>3svX?PqzU% zs81g#wgd1(^#(x49Y6?LIRfVF+|6uy1f1Qu`?mh3900dV09H_H_s(_R8qjVR5Qgg4 zfZ4l%1T?4y{I&~7W7gLIkURL`;N%GYV^8w<&faDXXt4)8gi_XmIeUOI6!|lFxCbag zAAANy_wQ!o{2BDwzjL>~34m}rcz}M}0By`L9y0dtP(mR1kUi;zCU9*Z5QU;QgY*Xg z-@Si1+WB+PA75SmhrA2D@fXnT;4buGU%;{hfFI*-Z6Nk=#}}UezEsNXAl)G#h~?A{ zK+GI1U7g^Te-5Eq18}hjyZ`C^BdCpsck_|x2Kx_z2T;3i5bFp~1hTsU5VsSc03DC+ z-mK|v@Z-_l`XKdyBS(NF6uloLIKEp&p?&}y=>bSU&ou!1cQk0B&-=j-$AAiu^%v{- z7!VR#?gtglHv}_>u%$??_le-}4FM~}d zcbfUX17OiIz=;2kGRPlD4JZ+d#qb|-aC;Y^`i&34 zzBPaf5%~{?=f5G$zy891I`g;EJL2Bojo%i!{=a&xJk1=O?XB(*>%Zya`dbME0{ff( ztq^VfKWF?$Ed&;<1K9lk4aEK>pY5mie+jGnYYFv_H-CcSf3L;<(YLtse>emC&%#Vo V6$SOTq}ttYi3kEIIt1=o{|`(2zsdjr delta 5350 zcmZ8l2Q=JI*WTUV5=8GMT9gPOY?SCC(OdK$mL=K}q9r1$g+%nlFVRH_QKJi1w~OeJ zh!)YJcR{pQ-v9T$-*>*5Gv_{Y@66n1?wND$%w)f!ZeOA*H=)K{{fe?3B!)ofI3SQq z5D4Uv2U^4*?KWj@?lCDz{d}OzmWgO*P`x4@PD=hHv_m76(bKI%cO3tSZ$ktfNw4lg zMSrx(o*<*HfujJH6<)Rfd=%pme%JkvqoHWkRRjH2D*lG;emG6WtF)cY*7?=%n?tMJ zsm>Ofp1O$WptqQ!&~AfOFGUTq#;bB&eErG1j9&3+S2+1@&a(4#cx`ylqwBw|`ZPXw zKl<(K>x;$2D5STw!PF9kqMxYRCcQiEp;dfE?picbotoWyTT;-kgRs*nnxUP^mB@JY z+FI(Zr!K#Sd9zsZU(g#LJyhtsM}wsh(Nvwad)UD||92vqIYTZxK4G zYvJj>zOS0AF#@ED12<rUX9y*MsKEU# zjopPYZWT?KzDGK^JynNdfQz}qPRX)r4rp5>5#NL{WOWA*Zg0eu^yB?a^po+LMhG6x zm8$gdVj>dj&bf{;CGW8?6=J-%rG(o_KHixk`o-r7^aS_y5uq4>Y2-jYzAfdPgJ-HoFs}&TipsAIwjbXYf>6s z;mgjS+Md!R-UdDHR&y8_#?o=|V6LL0V@LIJ%Sr@ULTQUO--|!`Kpv-HY?()zrs6)| zXYq&h3WDo{L7cvpejMkkGJh|UADKUo-(dYzF_m|VSklC4;l3~tpF}lhT9Nu2a|W&f zhOf}_EALUV*>ZQUdO$I;sO zk{I(jjbXj`;B>0{`K46d!iM&?t4hl@??~9c3UU>wEeUxizI1ufk-{?~#{H818m&W8 z;tWMiw7iF3b0wd#x~E0*y&UZ=X=@|ed+aQ!gV&!86!NNHh8v5G?G!3w8wDfRQAVQP zj6x})Fiozej!)R6%kK^iqpK!a>$4u(l&)hARr6AC0myanef{>5ewK-~d^>s-p4KKJ z#)xv$>P4x`AN%T;mKK=9@hJt5>|`m{aK_s>nJZE#QwXXK15+%a-=ef`E>iCasfbYh%^9)z-m^Y~i}bd+q2s#bXSl z(N-w#8n2a==*;jp&Kii#&1Byq6B%u@x2iI2a61ZF6w$!J_+3s5qE*Aw4CyI&V6It+|ug8@q5xU2X zLbM=AvQVv*FSVuh&ea=kbLzmOJW>qWlYX+JujfoCs9|^Q1=ALP8b^?AUsU`vvR@Q# zgHaY-`=+^(a|XgMIIv=P?P${F5)JI2k;kOBYr7J0#Ak5TilN&0*ZFLPSoSlEsA7%)OPjZ)OsTd3SJEkK^+rU#L9bRkkuO z8`AX@&1zFDk{CaP)%3B_5@K;-y!)ft27{4G|G4azMHSS}k$bwoPQn|)*^qC3wS7Q5$88?Aab<&yFjn$m{f0LQ&#<{ z>O-m9KaV9u`Pd%U1|N@;({~r1#DA$spxt=QwyS5_H!FR8?97C?HgbA$*4OrI&gD!b zMd66*z7_zdzsPkEhzs^97Zp~3nHA$?1lTweK_Dp<5D3+U#SUrj>fnyL`9JwE&&1ke zLXtjkpll`zIuWF<`PGh@T+R9$(lR1{J-K+y-g$Z4Q7fKRqjNj_^dMbJvnKJjdGG>v zIidI1bE7`OO0EH2Rmc4+NrI_z_r#5#U{qt`7$|mp`Uk%@@x5{fgBp@K4*tMpi$3Xe zqf?f>8L2uQ*XezG0rK56tVRRDTXX4S5CT)@xdtXnr2w^4opq&1>&$pL6=mx?-SOQ7Bh@jQBx?dCOgvDsj7xC ziw}Q!AdZ07O~&Os8Q0Bj8BMktBMlQ}q_kL!|FXMOIbfJIm9DDpb2kGU_sl&{Je|xV zz{8S3vgY#3n<8&j)eEXc{KW#0ves&wLPQTg!iL{af9MnyDy*1J;^Fd`y1|J+yGIc$ zng*^NLk-o)r!h~aU06)MN-(uMI`scK-8}Q(`_3{@Z;stMS(tL@7%_B!4xW=I<~<(~ zn5i&iZP7xCrbx9Iuq^vtHPbgNTHiqZ*hunnO4mdeFNas5*~-nGTqhbI!XI`!jasX$ zjfPq}kxm(m64iI;xhw5C-!NsPR}XR0zgeZgFcJMh@(RYWA%05-NkaIZ#qg3k7h&6- zV1WH%^SOEHRt2{)M8WffpszDM$2I<%OJu8?5FPnVx;FSV`ICXG0udGo*x)_gFN~v& z&mZ?Wi{?8G1l36-8Z$ndQln$*z7fsgx-b+s<*Xf135;5b(%SR*wGemE3t;^$inP^3 z?4VbbOye4U2tVi?=(GVNl@O!teJoCuR zW!Nbfk<**^VjEXsh-Bw1EF2!*=Dv8}AWHKvV?E4umOLeYP1xxpO z70=n`um?8h+Et1BC+;Qzfs;btM$5{KAgO6~j<%wm$3sdu#b{<{tEiNR>DR6@-L#*( z6b*d31gh;k^jxjJF`URyoTSjBj;`hZ;CyxF&Iu(4Chm>avS-7)#G4Oy!&~a6p8ZZT z)5r_J@b^8i{3)Bne|r?chRVQWawD~aV#>nCP1sjM^~Ob)F0WGXjz8N2B(>FeYxOu# zvAo@C8p zqJtB9(O|V5oIrLKcA1J6^lJg={#tAETY#Ueq+H?4Kn5HF2?66K0C|j#2b&ai)2b=C zRhEu{dBtSx-Dk^P@xss=KJ`^N?!`F!IwGGS++BSYezY0bZrw>S{BE@Kf!AW+R^Qy* z;b_oX~Ay%!XW^Huy{B^>*$cC7Lr9DrB3}N*!)y_I>k~Xns5FmeQ17 zN~`o>Rf$N3>tyPwBbhtfLC-D`yOq|9iUPA0>Za)`8szraTz}HjfR>P%sv4BuN5{xD zzu-y9K&u~9BNCRsdj>0}n5_q+MaGEpul>FwtMzNXSejgesMVPlBk;v)crlK;H21yF z4?1gUdIOp#Jb#kw5<*Lh%U_63A+K}#46!bdYKKfHWlueuziQx3dNMy;K+VdqN;j)V zBo%VQGult@)4YN0Afc@Y1{u#UW)po{o8g=F(Zn`aV6s%-BxlnfWpsENGy9Ogm&a;7 zg@brm9IVs7!?8$N!bU^)3KBxy>1@Yt=oXJaSgG8{k79h6xYI^58uPBqgb z#72b(pIBb;3O`)yJ8{fVjN65IOAKu0yDmZ72u^QHxUk2mZYsUUqh22~LZ&5lF`w@T zW#H>{QnnD01y!Vb%0I{Y?!=U!k>fi@pMU5J+&?GG_VPqiVeZ?vcVHmTZ^*3GCb|-)MVP-tno-d{jp_K_}&Cuzej;J%_I6m zsEKGR$+MqoIr=iqkaz0P_al^-uguYcoASZxntxRu^2Hh1!<>-Y%|kxLO|C^LE6l0i z=C+?Vb26ObDlt*#e)h|(e#UV(E^|8+<|&G13FlX6P1$X+d-iZF4J3!&Aa+K|IT>vi z2rk=7&FYZ}&JJy4r7YTs2IZ&4I3*cT+Mc3r7aL*)Mi1n0#g}{kQs=naPL^kr=}7BN4H3aq;re8uE9~Fj<_K z!_#lCaVO=+pTZJaVs+d{g-hx@@2LGkN+Qs2j8eU8!3I3pX#6&o&_A0{L!?ZXjeMh& zz^}Zr6}oVG$v;Qz>iPqIfkmorzbjwo5vCdu3Eof1Y}UgT&9gmoII|18TrGW;l(xFL zm9%JKhnNuGV!C?+mcWGOVwhQ27|CNo2^5g&CT^r;qxBQJzc1JdRAdtw4$)Cd_REBekU%hxH zuVms~cls?ne2oSbjU>@Pln$Ai28>*P6KNsgUlH?LUs<0xJWad8Wt*71+lR`s>iFur^sjysIXG7|~+RffOBTw|XsVJ@6^>degL4QW94wuXXdQDX)8+~7? z{K}^vrkn(CT2e$A-3)*qJ&7r)^+wp+PsZsk{i64ZhvCR^2 z_Y($8+yPWz@=V~w4#0Zl|6fY~URj|REjQSn38dKtWMM~FK;2#7HjIZIOT$eErtSiQ zFoVCC8T|1VhyTS_KsEqQ4mRun)Swgqr-b4DYI#A|zuM3}fCF~+S0M=Y?OjZ{$8muX zpwRvWw%`X%_kmk53_lpP56Hk;#6iM7AO%|%2cf?KMHs8ZU&(+yks=4z4*?R;^*6u; zGyboZ3@rW)NCO5PP>>4`Wdq^o04vCT08qkSc7VbMfE=u*19UwARDq3)A&qz_Gx+%c z5P)%ag1-*{(Mxwbp%7<}$6)Xozzb$XL1i)4T z!16cVtl;xM7en~Ez=m@`ic+@=fVd!ik&o@YoF94r+Y|NQC(%EJ8nS-JxI0N4E(;6k zPV$5+!K(fWMlgi{{QD5FCT(6Ev8{iU3{bd)gm4c4LHoM<-PeM`=z+g4FA_+=3mNPG z=4}!PMEpPNU&vf&*BF51;$i|zCNAPx1%(U3YNo&$C|na73;uw@8To`!4!$CfygVK` dczHWJJif0*bn%A?fv8^8Tp9@E)huw)`xh<4=KlZy diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 9bcc8b358e4..c3d1f4763ec 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, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `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, ParparVM/Android/iOS/JavaScript). Use when the project contains a `common/codenameone_settings.properties`, depends on `com.codenameone:codenameone-core`, edits CSS files under `common/src/main/css/`, calls `cn1:run`, `cn1:test`, `cn1:build`, references `com.codename1.ui.*` / `com.codename1.testing.*`, works in the `backend/` module (`cn1:backend`, `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 `. @@ -71,7 +71,7 @@ my-app/ ├── javascript/ # TeaVM-based 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 thin wrappers — touching them is almost always wrong unless you are intentionally writing a native interface. @@ -330,7 +330,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 0af0fd2a365..9b9c9c64f86 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 @@ -32,12 +32,11 @@ mvn -pl backend -Dcodename1.platform=backend cn1:backend-package 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.*`. @@ -49,14 +48,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 @@ -81,14 +80,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. | @@ -122,7 +121,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; } @@ -154,7 +153,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) { @@ -221,8 +220,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` 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. @@ -265,37 +264,72 @@ 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 +``` + +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 9309d857e91..c71dc2bc59c 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/JavaAPI/src/java/lang/AssertionError.java b/vm/JavaAPI/src/java/lang/AssertionError.java index ab3ea949c07..d0349cdc7c1 100644 --- a/vm/JavaAPI/src/java/lang/AssertionError.java +++ b/vm/JavaAPI/src/java/lang/AssertionError.java @@ -23,26 +23,50 @@ 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); } - + 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/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..a130d7110ec 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"); 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..8eb947206a8 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Access.java @@ -0,0 +1,291 @@ +/* + * 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 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..aa6c603312e 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: @@ -1010,6 +996,10 @@ public HttpServer.Response handle(HttpServer.Request request) break; } } + 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 // session, or starts one, would otherwise @@ -1064,6 +1054,12 @@ public HttpServer.Response handle(HttpServer.Request request) failed(request, attempted, startedMillis, err); throw err; } + 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 +1072,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 +1152,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 +1209,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 +1238,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 +1407,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 +1415,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 +1434,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 +1458,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 +1477,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 +1493,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 +1603,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 +1686,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 +1711,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 +1832,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 +1851,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 +1877,11 @@ public void register(HttpServer.WebSocketRegistry direct) } }; } + serving = new Serving(chain, sessions, tasks, requestLog, instrumented, app, + track, active != null ? active : Tracing.NONE, inFlight, + Cors.fromConfig(config), 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 +1923,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 +1940,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); } 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..3621eb3c220 --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Compression.java @@ -0,0 +1,158 @@ +/* + * 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 { + if (response == null || response.fileFd >= 0 || !acceptsGzip(request) + || response.status == 204 || response.status == 304 + || response.hasHeader("Content-Encoding") || !compressible(response.contentType)) { + return; + } + response.serializeDeferredJson(); + byte[] body = response.body; + if (body == null || body.length < minSize) { + 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"); + response.appendToken("Vary", "Accept-Encoding"); + } + + 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). `*` counts, as the specification says it does. + static boolean acceptsGzip(HttpServer.Request request) { + String accept = request.getHeader("Accept-Encoding"); + if (accept == null) { + return false; + } + 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(); + if ("gzip".equalsIgnoreCase(coding) || "x-gzip".equalsIgnoreCase(coding) + || "*".equals(coding)) { + return semi < 0 || !zeroQuality(item.substring(semi + 1)); + } + if (comma < 0) { + break; + } + pos = comma + 1; + } + return false; + } + + 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..11dddfd1d91 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,6 +175,7 @@ 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; @@ -177,6 +185,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 +201,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) { if (keysAndValues == null || keysAndValues.length == 0) { return this; } @@ -199,7 +213,7 @@ 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); + return new Config(baseFile, profileFile, merged, overrides, profile, loadedFrom); } /// Reads the configuration for this process: the active profile, then the two @@ -254,6 +268,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 +425,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()); @@ -411,7 +442,11 @@ public List keys() { /// 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; } 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..b3924de658e --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Cors.java @@ -0,0 +1,183 @@ +/* + * 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; + } + + /// 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 trailing + // slash never matches what a browser sends, so it is refused here + // rather than leaving a policy that silently allows no one. + if (!"*".equals(origin) && (origin.endsWith("/") || origin.indexOf("://") < 0)) { + 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; + } + + /// 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 HttpServer.Response.text(403, "origin not allowed"); + } + HttpServer.Response response = 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) { + String origin = request.getHeader("Origin"); + if (response == null || origin == null || !allows(origin) + || response.hasHeader("Access-Control-Allow-Origin")) { + return; + } + allowOrigin(response, origin); + if (exposed.trim().length() > 0) { + response.header("Access-Control-Expose-Headers", exposed); + } + } + + 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 { + response.header("Access-Control-Allow-Origin", origin); + response.appendToken("Vary", "Origin"); + } + if (credentials) { + response.header("Access-Control-Allow-Credentials", "true"); + } + } +} 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..f82cf8876f5 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,168 @@ 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 read with [#getBodyBytes]; asking for it + /// as text throws 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 boundary = Multipart.boundary(getHeader("Content-Type")); + byte[] data = boundary == null ? null : getBodyBytes(); + 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. + /// A malformed multipart body has no fields here; [#getParts] reports why. + 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.boundary(type) != null) { + Part p; + try { + p = getPart(name); + } catch (IllegalArgumentException malformed) { + return null; + } + 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 +1292,45 @@ 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. + void appendToken(String name, String token) { + String key = name; + String existing = null; + 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()); + existing = e.getValue() == null ? null : String.valueOf(e.getValue()); + } + } + } + header(key, existing == null || existing.trim().length() == 0 ? token + : existing + ", " + token); + } + private static byte[] bytes(String s) { try { return s == null ? new byte[0] : s.getBytes("UTF-8"); @@ -4678,6 +4905,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 +4918,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 +6108,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 +6185,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 +7734,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 +7752,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 +7763,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 +7780,124 @@ 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 { + 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). + 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))); + com.codename1.io.gzip.GZIPInputStream in = null; + try { + in = new com.codename1.io.gzip.GZIPInputStream( + new java.io.ByteArrayInputStream(data, offset, length)); + byte[] chunk = new byte[8192]; + 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); + } + } 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. + } + } + } + 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..166c1f80eae --- /dev/null +++ b/vm/backend/src/com/codename1/backend/Multipart.java @@ -0,0 +1,321 @@ +/* + * 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 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] == '-') { + 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(); + while (!crlfAt(body, pos)) { + int end = indexOfCrLf(body, pos); + if (end < 0 || end - pos > 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 = indexOf(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; + } + } + + /// 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. + byte[] utf = utf8(String.valueOf(c)); + 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; + } + + private static int indexOf(byte[] data, byte[] needle, int from) { + int last = data.length - needle.length; + byte first = needle[0]; + for (int iter = from ; iter <= last ; iter++) { + if (data[iter] != first) { + continue; + } + int k = 1; + while (k < needle.length && data[iter + k] == needle[k]) { + k++; + } + if (k == needle.length) { + return iter; + } + } + 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..06cc8d7927a --- /dev/null +++ b/vm/backend/src/com/codename1/impl/backend/BackendAccess.java @@ -0,0 +1,237 @@ +/* + * 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; + + /// 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..deed7ac7f8c --- /dev/null +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java @@ -0,0 +1,743 @@ +/* + * 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) { + 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) { + 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 (!same(e, a)) { + throw new AssertionFailedError(prefix(message) + "array contents differ at index [" + + iter + "], expected: <" + e + "> but was: <" + a + ">"); + } + } + } + + 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 (!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; + } + 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) { + return assertThrows(expectedType, executable, text(message)); + } + + 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) { + 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) { + 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) { + 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 (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 + + 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/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/EnableAsync.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/EnableAsync.java rename to vm/backend/test/parparvm/com/codename1/impl/backend/test/BackendTestExtension.java index d6997d46a78..696854e2085 100644 --- a/vm/backend/src/com/codename1/backend/annotations/EnableAsync.java +++ b/vm/backend/test/parparvm/com/codename1/impl/backend/test/BackendTestExtension.java @@ -20,15 +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: [Async] works without it. -@Retention(RetentionPolicy.CLASS) -@Target(ElementType.TYPE) -public @interface EnableAsync { +/// 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..bacd68d6239 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/BackendTest.java @@ -0,0 +1,80 @@ +/* + * 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.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. 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. +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ExtendWith(com.codename1.impl.backend.test.BackendTestExtension.class) +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; no requests are made. + 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/src/com/codename1/backend/annotations/EnableScheduling.java b/vm/backend/test/src/com/codename1/backend/test/HttpMethod.java similarity index 75% rename from vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java rename to vm/backend/test/src/com/codename1/backend/test/HttpMethod.java index 139b0ba97c6..51116c72eda 100644 --- a/vm/backend/src/com/codename1/backend/annotations/EnableScheduling.java +++ b/vm/backend/test/src/com/codename1/backend/test/HttpMethod.java @@ -20,16 +20,9 @@ * 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.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 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..1e259215f1c --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/HttpStatus.java @@ -0,0 +1,185 @@ +/* + * 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() { + return code >= 400; + } + + /// 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 code >= 400; + } + + @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..cd1e6643cd8 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/JsonPathResultMatchers.java @@ -0,0 +1,149 @@ +/* + * 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); + } + }; + } + + public ResultMatcher exists() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Matchers.check(!JsonPath.isMissing(read(result)), "No value at JSON path \"" + + expression + "\""); + } + }; + } + + public ResultMatcher doesNotExist() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(JsonPath.isMissing(actual), "Expected no value at JSON path \"" + + expression + "\" but found: " + actual); + } + }; + } + + public ResultMatcher isEmpty() { + return new ResultMatcher() { + @Override + public void match(MvcResult result) throws Exception { + Object actual = read(result); + Matchers.check(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 || JsonPath.isMissing(value)) { + 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..59604244c5f --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMultipartRequestBuilder.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.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 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..6923589a4c0 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockMvc.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.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(); + HttpServer.Response response = access.dispatch(backend, built.method, built.target, + built.headers, built.body); + MockResponse mock = new MockResponse(access.status(response), access.contentType(response), + access.headers(response), access.body(response)); + 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..be21f138495 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockRequestBuilder.java @@ -0,0 +1,250 @@ +/* + * 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) { + for (String value : values) { + params.add(new String[] {name, value}); + } + return this; + } + + /// The same as [#param]. + public MockRequestBuilder queryParam(String name, String... values) { + return param(name, values); + } + + /// 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)); + } + if (!out.headers.containsKey("Host")) { + out.headers.put("Host", "localhost"); + } + return out; + } + + /// 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..34095c388b5 --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/MockResponse.java @@ -0,0 +1,113 @@ +/* + * 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; + this.headers = headers; + 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..285ed25eacf --- /dev/null +++ b/vm/backend/test/src/com/codename1/backend/test/ResultActions.java @@ -0,0 +1,66 @@ +/* + * 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. + public ResultActions andExpectAll(ResultMatcher... matchers) throws Exception { + StringBuilder failures = null; + for (ResultMatcher value : matchers) { + try { + value.match(result); + } catch (AssertionError failed) { + if (failures == null) { + failures = new StringBuilder("Multiple expectations failed:"); + } + failures.append("\n\t").append(failed.getMessage()); + } + } + 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..e9482b01286 --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestContexts.java @@ -0,0 +1,119 @@ +/* + * 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; + 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..fdac8ac2251 --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.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.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) { + restTemplate = new TestRestTemplate("http://127.0.0.1:" + 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..c542c3ca281 --- /dev/null +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestRun.java @@ -0,0 +1,139 @@ +/* + * 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; + } + + private 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/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/CleanTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/CleanTargetIntegrationTest.java index 43f425e3363..65b86da194d 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 @@ -1265,7 +1265,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 +1303,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 +1323,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,7 +1331,7 @@ public void run() { }); sreader.setDaemon(true); sreader.start(); - assertTrue(ready.await(30, java.util.concurrent.TimeUnit.SECONDS), + assertTrue(ready.await(900, java.util.concurrent.TimeUnit.SECONDS), "cn1ss server should start listening"); // Launch the suite exe and watch its stdout for the end marker. The 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..5d5dc45cd65 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 @@ -361,26 +361,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 +379,14 @@ 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"); + assertTrue(ready.await(900, TimeUnit.SECONDS), "cn1ss server should start listening"); 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; + } +} From 3d2db4eee774cf7d0d0a18fc3100d005ce108897 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:26:34 +0300 Subject: [PATCH 02/12] Record the vendored JZlib sources in the copyright-header exclusions They are touched only to mark them @SharedWithBackend, which pulls them into the diff-scoped header check; they keep JCraft's BSD notice. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/copyright-header-exclusions.txt | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index da078059931..82ca1a5d1b8 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -243,3 +243,23 @@ vm/JavaAPI/src/java/text/DateFormatSymbols.java | Codename One contribution whos vm/JavaAPI/src/java/text/Format.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written vm/JavaAPI/src/java/text/ParseException.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written vm/JavaAPI/src/java/text/SimpleDateFormat.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written +CodenameOne/src/com/codename1/io/gzip/Adler32.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/CRC32.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/Checksum.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/Deflate.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/Deflater.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/DeflaterOutputStream.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/GZIPException.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/GZIPHeader.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/GZIPInputStream.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/GZIPOutputStream.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/InfBlocks.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/InfCodes.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/InfTree.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/Inflate.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/Inflater.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/InflaterInputStream.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/JZlib.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/StaticTree.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/Tree.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice +CodenameOne/src/com/codename1/io/gzip/ZStream.java | JZlib, Copyright ymnk, JCraft Inc., BSD license (based on zlib 1.1.3), retaining its original notice From ac8d117962b65ec33a33113caec2dafa01871743 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:44:43 +0300 Subject: [PATCH 03/12] Fix the CI failures and the review findings on the backend testing PR CI: - The packaged archetype server refused to start on the compiled-in ${DATABASE_URL}: an unset reference no longer counts as a configured database when nothing needs one (Config.resolves); code that needs one still fails loudly. - The Gradle conversion kept the archetype's untouched backend once it carried the sample tests; the generated tests and their dependencies are part of the skeleton. - oteltest used Builder.management(), now internal; BackendOtelTest counted a bridge method's parameter type as McpServer being linked. - The device legs' backend tests: a failed request threw instead of failing (the suite timed out), and a step that hears nothing now fails by name; Android allows cleartext to the CI host; the CI JS, Linux and Windows launchers instantiate the generated bootstraps a real build does. - Ports the tests found broken: the Linux and Windows ports ignored the HTTP method, the connect and read timeouts, and (Windows) followed redirects natively; watchOS and tvOS had no HTTP at all (an NSURLSession path); the JS port answered null for every response header and stored binary bytes, and String.getBytes, unsigned. - LanguageTool terms. Review: - Compression and CORS no longer mutate a Response a handler shares. - An explicit gzip entry in Accept-Encoding wins over *. - The compiled runner: unannotated overrides hide inherited tests, package-private base-class methods are reached through a helper in their package, it runs the classes Surefire selects, and duplicate injected field names are a build error. - MockMvc: a lower-case Host is kept, and Content-Type is visible as a header. - Gradle: Kotlin tests get the test pass, and the properties files are task inputs. - server.sh installs and digests the build engine. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../html5/js/ajax/XMLHttpRequest.java | 7 + .../impl/html5/HTML5Implementation.java | 2 +- .../impl/html5/NetworkConnection.java | 20 +- Ports/JavaScriptPort/src/main/webapp/port.js | 26 +- Ports/LinuxPort/nativeSources/cn1_linux_net.c | 67 +++++ .../impl/linux/LinuxImplementation.java | 27 +- .../com/codename1/impl/linux/LinuxNative.java | 12 + .../nativeSources/cn1_windows_net.c | 119 ++++++++- .../impl/windows/WindowsImplementation.java | 26 +- .../codename1/impl/windows/WindowsNative.java | 11 + .../nativeSources/NetworkConnectionImpl.h | 4 +- .../nativeSources/NetworkConnectionImpl.m | 113 +++++++-- docs/developer-guide/Backend-Testing.asciidoc | 5 + docs/developer-guide/languagetool-accept.txt | 2 + .../com/codename1/backend/BackendTest.java | 37 +++ .../com/codename1/backend/ConfigTest.java | 19 ++ .../com/codename1/backend/HttpBodiesTest.java | 48 ++++ .../codename1/maven/BackendTestPackager.java | 9 +- .../com/codename1/maven/GradleConversion.java | 25 +- .../processors/BackendTestGenerator.java | 154 ++++++++++-- .../maven/processors/BackendTests.java | 37 ++- .../codename1/maven/GradleConversionTest.java | 28 +++ .../processors/BackendTestGeneratorTest.java | 51 ++++ .../spotbugs-exclude.xml | 10 +- .../com/codename1/gradle/BackendSupport.java | 50 +++- .../tasks/ProcessTestAnnotationsAction.java | 11 +- .../com/codename1/maven/BackendTestMojo.java | 10 + .../codename1/maven/SurefireSelection.java | 158 ++++++++++++ .../maven/SurefireSelectionTest.java | 94 +++++++ .../build-javascript-port-hellocodenameone.sh | 17 +- scripts/hellocodenameone/backend/server.sh | 13 +- .../hellocodenameone/backend/ProbeApi.java | 2 +- .../backend/ProbeApiTest.java | 14 ++ .../common/codenameone_settings.properties | 3 + .../tests/backend/BackendClientTest.java | 50 +++- .../backend/BackendConnectionRequestTest.java | 8 +- .../src/javascript/parparvm_runtime.js | 6 +- .../demo/oteltest/com/demo/OtelServer.java | 21 +- .../src/com/codename1/backend/Backend.java | 15 +- .../com/codename1/backend/Compression.java | 18 +- .../src/com/codename1/backend/Config.java | 29 ++- .../backend/test/MockRequestBuilder.java | 14 +- .../codename1/backend/test/MockResponse.java | 15 +- .../tools/translator/BackendOtelTest.java | 28 ++- .../CleanTargetIntegrationTest.java | 30 +++ .../CleanTargetLinuxIntegrationTest.java | 4 + .../JavascriptNetworkBindingsTest.java | 236 ++++++++++++++++++ 47 files changed, 1582 insertions(+), 123 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SurefireSelection.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/SurefireSelectionTest.java create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/JavascriptNetworkBindingsTest.java 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..33fecf154f7 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 @@ -12493,7 +12493,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..4ff8a5033da 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 @@ -167,7 +167,7 @@ public InputStream openInputStream() throws IOException { 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()); @@ -260,10 +260,26 @@ 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. + */ + @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 78bf2cbd3df..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"); }); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_net.c b/Ports/LinuxPort/nativeSources/cn1_linux_net.c index 48c4d096669..733a9e520d6 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: PUT, PATCH, DELETE, HEAD... NULL for the + * GET or POST the post flag already selects. */ + 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) { @@ -159,6 +171,25 @@ static void cn1HttpPerform(CN1Http* c) { curl_easy_setopt(c->easy, CURLOPT_READDATA, c); curl_easy_setopt(c->easy, CURLOPT_POSTFIELDSIZE, (long) c->reqLen); } + if (c->method != 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. */ CN1_YIELD_THREAD; @@ -166,6 +197,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 +227,40 @@ 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); + c->method = (m == 0 || strcmp(m, "GET") == 0 || strcmp(m, "POST") == 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 +413,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..c2822c8f3e4 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -2582,6 +2582,23 @@ 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); + } + + @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 +2698,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..fe862b3620b 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: PUT, PATCH, DELETE, HEAD... NULL for the + * GET or POST the post flag selects. */ + 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,6 +121,20 @@ 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, @@ -105,11 +144,13 @@ static BOOL cn1NetEnsureSent(CN1Connection* conn) { conn->pendingLen, 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 +320,57 @@ 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); + if (wcscmp(verb, L"GET") == 0 || wcscmp(verb, L"POST") == 0) { + free(verb); + conn->verb = NULL; + } else { + 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 +402,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); @@ -543,6 +648,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..f70c023a5a1 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -2592,6 +2592,23 @@ 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); + } + + @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 +2704,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/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index d810068c0d6..122a159b3f9 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); 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/developer-guide/Backend-Testing.asciidoc b/docs/developer-guide/Backend-Testing.asciidoc index 9fedef85ef1..4fe1248dd75 100644 --- a/docs/developer-guide/Backend-Testing.asciidoc +++ b/docs/developer-guide/Backend-Testing.asciidoc @@ -264,6 +264,11 @@ and `@Tag`, `Assumptions`, and the `Assertions` methods: `assertEquals`, `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; under Gradle, every test class. Tag +selection isn't applied to it. + The results go to `target/surefire-reports` as `TEST--compiled.xml`, beside the JVM run's reports, and a failed test fails the build. 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/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 429be6271b1..4fd6defd2e1 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,25 @@ 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 { diff --git a/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java b/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java index ad412412f9e..14e7558d037 100644 --- a/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/HttpBodiesTest.java @@ -57,6 +57,17 @@ void 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 { @@ -98,6 +109,9 @@ public HttpServer.Response handle(HttpServer.Request request) throws Exception { return HttpServer.Response.text(200, request.param("a") + "," + request.param("b")); } + if (path.startsWith("/shared")) { + return SHARED; + } if (path.startsWith("/big")) { StringBuilder sb = new StringBuilder(); for (int iter = 0 ; iter < 500 ; iter++) { @@ -262,6 +276,40 @@ void compressionWhenAccepted() throws Exception { 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 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) 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 index 380c8cf5774..67208154e20 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/BackendTestPackager.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendTestPackager.java @@ -78,6 +78,13 @@ protected BackendTestPackager(ProjectHost host) { /// 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; + } + /// Sets [#strict]. public BackendTestPackager strict(boolean value) { this.strict = value; @@ -158,7 +165,7 @@ protected void afterGenerate(File jdk, File javaApi, File classes, File work) List cp = new ArrayList(classpath); cp.add(javaApi.getAbsolutePath()); BackendTests.process(classes, testClasses, new File(work, "test-stubs"), - host.baseDir(), roots, sourceEncoding(), cp, true, getLog()); + host.baseDir(), roots, sourceEncoding(), cp, true, this::selectsTestClass, 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 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/processors/BackendTestGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BackendTestGenerator.java index b512f5bc2d4..8efbe960d7a 100644 --- 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 @@ -105,6 +105,8 @@ final class BackendTestGenerator { 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 { @@ -122,14 +124,20 @@ private static final class Spec { } private BackendTestGenerator(ProcessorContext ctx, Set mainClasses, - Set testClasses, File mainOut, boolean compiled) { + 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. 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`. @@ -137,8 +145,15 @@ private BackendTestGenerator(ProcessorContext ctx, Set mainClasses, /// @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); + compiled, selection); return g.run(); } @@ -177,10 +192,17 @@ private int run() throws ProcessingException { } for (String name : new TreeSet(testClasses)) { AnnotatedClass cls = ctx.lookup(name); - if (cls != null && runner(cls, byTest.get(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()) { @@ -450,6 +472,7 @@ private String context(Spec spec) { .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()) { @@ -459,6 +482,17 @@ private String context(Spec spec) { 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"); } @@ -553,10 +587,21 @@ private boolean runner(AnnotatedClass cls, Spec spec) { List chain = hierarchy(cls); List tests = new ArrayList(); Set seen = new LinkedHashSet(); + java.util.IdentityHashMap owners = + new java.util.IdentityHashMap(); for (AnnotatedClass c : chain) { for (MethodInfo m : c.getMethods()) { - if (m.getAnnotation(JUNIT_TEST) != null && seen.add(m.getName() + m.getDescriptor())) { - tests.add(m); + String signature = m.getName() + m.getDescriptor(); + if (m.getAnnotation(JUNIT_TEST) != null) { + if (seen.add(signature)) { + tests.add(m); + owners.put(m, c); + } + } else if (hides(m)) { + // An override without @Test is not a test, and it hides the + // superclass's: JUnit runs neither. Collected anyway, the + // generated call dispatched to the override. + seen.add(signature); } } } @@ -600,12 +645,12 @@ private boolean runner(AnnotatedClass cls, Spec spec) { runners.add(runnerBinary); return true; } - List beforeAll = lifecycle(chain, JUNIT_BEFORE_ALL, true, true); - List afterAll = lifecycle(chain, JUNIT_AFTER_ALL, true, false); + 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"); for (MethodInfo m : beforeAll) { - sb.append(" if (ready) {\n try {\n ").append(testType) - .append('.').append(m.getName()).append("();\n") + sb.append(" if (ready) {\n try {\n ") + .append(call(owners.get(m), m, pkg, testType)).append(";\n") .append(" } catch (Throwable err) {\n") .append(" com.codename1.impl.backend.test.TestRun.lifecycleFailed(CLS, ") .append(quote("@BeforeAll " + m.getName())).append(", err);\n") @@ -628,14 +673,14 @@ private boolean runner(AnnotatedClass cls, Spec spec) { index++; } for (MethodInfo m : afterAll) { - sb.append(" try {\n ").append(testType).append('.').append(m.getName()) - .append("();\n } catch (Throwable err) {\n") + 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); - List afterEach = lifecycle(chain, JUNIT_AFTER_EACH, false, false); + 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); @@ -652,15 +697,15 @@ private boolean runner(AnnotatedClass cls, Spec spec) { + ".acquire(context));\n"); } for (MethodInfo b : beforeEach) { - sb.append(" test.").append(b.getName()).append("();\n"); + sb.append(" ").append(call(owners.get(b), b, pkg, "test")).append(";\n"); } - sb.append(" test.").append(m.getName()).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 test.").append(a.getName()) - .append("();\n } catch (Throwable err) {\n") + sb.append(" try {\n ").append(call(owners.get(a), a, pkg, "test")) + .append(";\n } catch (Throwable err) {\n") .append(" if (failure == null) {\n") .append(" failure = err;\n }\n") .append(" }\n"); @@ -679,13 +724,23 @@ private boolean runner(AnnotatedClass cls, Spec spec) { /// 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) { + boolean isStatic, boolean superFirst, + java.util.Map owners) { List out = new ArrayList(); Set seen = new LinkedHashSet(); for (AnnotatedClass c : chain) { List level = new ArrayList(); for (MethodInfo m : c.getMethods()) { - if (m.getAnnotation(annotation) != null && seen.add(m.getName() + m.getDescriptor())) { + if (m.getAnnotation(annotation) == null) { + // As for tests: an unannotated override or hiding method is + // what JUnit sees, and it is not a lifecycle method. + if (hides(m)) { + seen.add(m.getName() + m.getDescriptor()); + } + continue; + } + if (seen.add(m.getName() + m.getDescriptor())) { + owners.put(m, c); if (m.isStatic() != isStatic) { ctx.error(c, m.getName() + " is " + (isStatic ? "not " : "") + "static; " + annotation.substring(annotation.lastIndexOf('/') + 1, @@ -706,6 +761,67 @@ private List lifecycle(List chain, String annotation return out; } + /// Whether `m` hides an inherited method of the same signature from JUnit when + /// it carries no annotation of its own: any method but a private one or a + /// constructor. + private static boolean hides(MethodInfo m) { + return !m.isPrivate() && !m.isConstructor() && !m.isSynthetic(); + } + + /// 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()) { 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 index 2efd8a49f8c..a5bcfb9123d 100644 --- 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 @@ -55,11 +55,40 @@ public static int process(File mainClasses, File testClasses, File stubs, File p List sourceRoots, String encoding, List classpath, boolean compiled, Log log) throws BuildExecutionException, BuildFailureException { - Map main; + 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. + 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 { + 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 { - main = mainClasses.isDirectory() ? ClassScanner.scan(mainClasses) - : new LinkedHashMap(); + for (File dir : mainClassDirs) { + if (dir.isDirectory()) { + main.putAll(ClassScanner.scan(dir)); + if (new File(dir, RestControllerAnnotationProcessor.WIRING_RESOURCE).isFile()) { + mainClasses = dir; + } + } + } tests = ClassScanner.scan(testClasses); } catch (ProcessingException err) { throw new BuildExecutionException("Could not scan the compiled classes: " @@ -77,7 +106,7 @@ public static int process(File mainClasses, File testClasses, File stubs, File p int count; try { count = BackendTestGenerator.generate(ctx, mainNames, tests.keySet(), mainClasses, - compiled); + compiled, selection); } catch (ProcessingException err) { throw new BuildExecutionException(err.getMessage(), err); } 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/BackendTestGeneratorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/BackendTestGeneratorTest.java index 4c172442b2a..f813ffa5c62 100644 --- 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 @@ -51,6 +51,7 @@ 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; @@ -169,6 +170,56 @@ public void anAmbiguousInjectionIsABuildError() throws Exception { } } + @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" + + "}\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" + + " @org.junit.jupiter.api.Test void own() { calls.add(\"own\"); }\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); + @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")); + } 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); diff --git a/maven/codenameone-gradle-plugin/spotbugs-exclude.xml b/maven/codenameone-gradle-plugin/spotbugs-exclude.xml index 4fc5d753303..4365646537e 100644 --- a/maven/codenameone-gradle-plugin/spotbugs-exclude.xml +++ b/maven/codenameone-gradle-plugin/spotbugs-exclude.xml @@ -23,13 +23,17 @@ - + - + + + + + classpath, the main source set's class directories, and the other test + compiler's output and sources, resolved only when the test pass runs. --> @@ -33,6 +33,8 @@ + + + + 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/vm/backend/src/com/codename1/backend/Backend.java b/vm/backend/src/com/codename1/backend/Backend.java index a741e37e5b8..700aef80530 100644 --- a/vm/backend/src/com/codename1/backend/Backend.java +++ b/vm/backend/src/com/codename1/backend/Backend.java @@ -1886,9 +1886,15 @@ 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.fromConfig(config), Compression.fromConfig(config)); + cors, Compression.fromConfig(config)); server = HttpServer.start(bindHost, listenPort, listenBacklog, workerCount, serving, context, routes, active != null ? active : Tracing.NONE); bound = true; diff --git a/vm/backend/src/com/codename1/backend/Cors.java b/vm/backend/src/com/codename1/backend/Cors.java index 0eae693ba57..aa5f481194c 100644 --- a/vm/backend/src/com/codename1/backend/Cors.java +++ b/vm/backend/src/com/codename1/backend/Cors.java @@ -64,6 +64,15 @@ private Cors(List origins, String methods, String headers, String exposed, 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); @@ -133,7 +142,7 @@ boolean allows(String origin) { /// 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, request.getHeader("Host"))) { + if (origin == null || sameOrigin(origin, requestScheme(request), request.getHeader("Host"))) { return null; } String asked = request.getHeader("Access-Control-Request-Method"); @@ -167,18 +176,38 @@ private boolean allowsMethod(String method) { return false; } - /// Whether `origin` names the server the request was sent to: its host and - /// port equal the Host header's, a default port written or not. - private static boolean sameOrigin(String origin, String host) { + /// 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) { + if (scheme < 0 || !origin.substring(0, scheme).equalsIgnoreCase(requestScheme)) { return false; } String authority = origin.substring(scheme + 3); - boolean https = origin.regionMatches(true, 0, "https", 0, 5) && scheme == 5; + boolean https = "https".equalsIgnoreCase(requestScheme); return withoutDefaultPort(authority, https).equalsIgnoreCase(withoutDefaultPort(host.trim(), https)); } 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 index 03994172f3f..800e95b48be 100644 --- a/vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java +++ b/vm/backend/test/junit-shim/org/junit/jupiter/api/Assertions.java @@ -251,6 +251,7 @@ public static void assertEquals(float expected, float actual, float delta) { } 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); @@ -279,6 +280,7 @@ public static void assertEquals(double expected, double actual, double delta) { } 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); @@ -769,6 +771,16 @@ public static void assertAll(String heading, Executable... executables) { // --------------------------------------------------------------- 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. 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 index fdac8ac2251..6247d841a6f 100644 --- a/vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.java +++ b/vm/backend/test/src/com/codename1/impl/backend/test/TestEnvironment.java @@ -71,7 +71,12 @@ public MockMvc mockMvc() { public TestRestTemplate restTemplate() { if (restTemplate == null) { - restTemplate = new TestRestTemplate("http://127.0.0.1:" + port()); + // 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; }