This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 5ba7c4b4a1 Finalize TODO-105 container timing workflow
5ba7c4b4a1 is described below
commit 5ba7c4b4a121cbf36cd68e134544c014477f7964
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 09:39:52 2026 -0400
Finalize TODO-105 container timing workflow
---
juneau-utest/pom.xml | 20 ++
.../microservice/Microservice_Builder_Test.java | 1 +
.../microservice/Microservice_Inject_Test.java | 1 +
.../Microservice_Listener_Fanout_Test.java | 1 +
.../Microservice_OverridingBeanStore_Test.java | 1 +
.../Microservice_PushPopOverlay_Test.java | 1 +
.../jetty/JettyConfiguration_Test.java | 1 +
.../RestPathsRuntimeOverride_JettyMount_Test.java | 1 +
.../juneau/microservice/jetty/Rest_Paths_Test.java | 1 +
...asicVersionResource_JettyMicroservice_Test.java | 1 +
.../BasicVersionResource_Springboot_Test.java | 1 +
.../docs/BasicApiDocs_JettyMicroservice_Test.java | 1 +
...iDocs_Springboot_MultiOpenApiProvider_Test.java | 1 +
.../rest/docs/BasicApiDocs_Springboot_Test.java | 1 +
.../BasicEchoResource_JettyMicroservice_Test.java | 1 +
.../ops/BasicEchoResource_Springboot_Test.java | 1 +
.../RestPathsRuntimeOverride_Springboot_Test.java | 1 +
.../rest/springboot/SpringBeanStore_Test.java | 1 +
...tPropertySource_SpringbootIntegration_Test.java | 1 +
.../SpringEnvironmentPropertySource_Test.java | 1 +
...StaticFilesResource_JettyMicroservice_Test.java | 1 +
...StaticFilesResource_SpringbootMetaInf_Test.java | 1 +
.../BasicStaticFilesResource_Springboot_Test.java | 1 +
.../testing/annotations/JettyMicroserviceTest.java | 30 ++
.../juneau/testing/annotations/SpringbootTest.java | 30 ++
scripts/check-container-tags.py | 86 ++++++
scripts/push-timings.py | 109 +++++++
scripts/push.py | 49 ++-
scripts/surefire-rollup.py | 166 ++++++++++
scripts/test.py | 315 ++++++++++++-------
todo/FINISHED-105-container-tag-perf-tracking.md | 54 ++++
todo/FINISHED.md | 2 +
todo/TODO-105-container-tag-perf-tracking.md | 340 +++++++++++++++++++++
todo/TODO.md | 9 +-
34 files changed, 1113 insertions(+), 119 deletions(-)
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index a65e85c2d6..9ed5cbcfc5 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -331,6 +331,26 @@
<java.locale.providers>JRE,COMPAT,SPI,CLDR</java.locale.providers>
</systemPropertyVariables>
</configuration>
+ <executions>
+ <execution>
+ <id>default-test</id>
+ <configuration>
+
<excludedGroups>container</excludedGroups>
+
<reportsDirectory>${project.build.directory}/surefire-reports/core</reportsDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>container-test</id>
+ <phase>test</phase>
+ <goals>
+ <goal>test</goal>
+ </goals>
+ <configuration>
+
<groups>container</groups>
+
<reportsDirectory>${project.build.directory}/surefire-reports/container</reportsDirectory>
+ </configuration>
+ </execution>
+ </executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Builder_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Builder_Test.java
index 077921b224..c16960cdd9 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Builder_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Builder_Test.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.*;
/**
* Tests for {@link Microservice.Builder} methods.
*/
[email protected]
class Microservice_Builder_Test extends TestBase {
@Test void a01_create() {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
index 14e8569555..0cacfa0f21 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Inject_Test.java
@@ -36,6 +36,7 @@ import org.junit.jupiter.api.*;
* that explicit builder calls always win, and that the bean store is closed on
* {@link Microservice#stop()} so <c>@PreDestroy</c> hooks fire.
*/
[email protected]
class Microservice_Inject_Test extends TestBase {
//-----------------------------------------------------------------------------------------------------------------
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Listener_Fanout_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Listener_Fanout_Test.java
index 04d8b9ce18..8be2894cd0 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Listener_Fanout_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_Listener_Fanout_Test.java
@@ -34,6 +34,7 @@ import org.junit.jupiter.api.*;
* for each lifecycle event ({@link Microservice#start()}, {@link
Microservice#stop()}, and config-change
* notifications). {@code onStart} runs in registration order; {@code onStop}
runs in the reverse order.
*/
[email protected]
class Microservice_Listener_Fanout_Test extends TestBase {
/**
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_OverridingBeanStore_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_OverridingBeanStore_Test.java
index 7e79e9c54f..523ec940e2 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_OverridingBeanStore_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_OverridingBeanStore_Test.java
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.*;
* resolve before locally-registered beans during the microservice's
startup-time injection
* (Phase 4 of TODO-35).
*/
[email protected]
class Microservice_OverridingBeanStore_Test extends TestBase {
interface ExternalApi {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_PushPopOverlay_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_PushPopOverlay_Test.java
index 0645bf2eae..350e7ccb16 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_PushPopOverlay_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/Microservice_PushPopOverlay_Test.java
@@ -41,6 +41,7 @@ import org.junit.jupiter.api.*;
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.MethodName.class)
[email protected]
class Microservice_PushPopOverlay_Test extends TestBase {
interface ExternalApi {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyConfiguration_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyConfiguration_Test.java
index d15b8c0cbd..75d8bb5b6c 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyConfiguration_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/JettyConfiguration_Test.java
@@ -39,6 +39,7 @@ import jakarta.servlet.http.*;
* that auto-discovered <c>@Rest</c> servlets are mounted at
<c>@Rest(path=...)</c>, and that duplicate
* mount paths fail fast.
*/
[email protected]
class JettyConfiguration_Test extends TestBase {
private static Microservice create(Class<?>... configurations) throws
Exception {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/RestPathsRuntimeOverride_JettyMount_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/RestPathsRuntimeOverride_JettyMount_Test.java
index 150b5e83b4..ef2e890f68 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/RestPathsRuntimeOverride_JettyMount_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/RestPathsRuntimeOverride_JettyMount_Test.java
@@ -53,6 +53,7 @@ import jakarta.servlet.*;
*
* @since 9.5.0
*/
[email protected]
class RestPathsRuntimeOverride_JettyMount_Test extends TestBase {
private static Microservice create(Class<?>... configurations) throws
Exception {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
index bddb41e286..04f8a7a8c4 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/microservice/jetty/Rest_Paths_Test.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.*;
import jakarta.servlet.*;
[email protected]
class Rest_Paths_Test extends TestBase {
private static Microservice create(Class<?>... configurations) throws
Exception {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
index 1cb050315c..7721db0b06 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_JettyMicroservice_Test.java
@@ -57,6 +57,7 @@ import jakarta.servlet.*;
*
* @since 9.5.0
*/
[email protected]
class BasicVersionResource_JettyMicroservice_Test extends TestBase {
@Rest(mixins=BasicVersionResource.class)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
index 5cbf1b6d7e..6c8ba80b44 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/convention/BasicVersionResource_Springboot_Test.java
@@ -58,6 +58,7 @@ import org.springframework.test.annotation.*;
*
* @since 9.5.0
*/
[email protected]
@SpringBootTest(classes = BasicVersionResource_Springboot_Test.TestApp.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_JettyMicroservice_Test.java
index d1b080f977..075156bb78 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_JettyMicroservice_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_JettyMicroservice_Test.java
@@ -64,6 +64,7 @@ import jakarta.servlet.*;
*
* @since 9.5.0
*/
[email protected]
class BasicApiDocs_JettyMicroservice_Test extends TestBase {
/** Test host: vanilla {@link BasicRestServlet} subclass; inherits the
four-mixin pack. */
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_MultiOpenApiProvider_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_MultiOpenApiProvider_Test.java
index 662d80672f..28f7deb62a 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_MultiOpenApiProvider_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_MultiOpenApiProvider_Test.java
@@ -68,6 +68,7 @@ import org.springframework.context.annotation.*;
*
* @since 9.5.0
*/
[email protected]
class BasicApiDocs_Springboot_MultiOpenApiProvider_Test {
/** Spring Boot app config with two {@link OpenApiProvider} beans, one
{@code @Primary}. */
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_Test.java
index 748b963905..0e13ab00e8 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/docs/BasicApiDocs_Springboot_Test.java
@@ -71,6 +71,7 @@ import org.springframework.test.annotation.*;
*
* @since 9.5.0
*/
[email protected]
@SpringBootTest(classes = BasicApiDocs_Springboot_Test.TestApp.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
index ec5212d2f9..f106120c43 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
@@ -54,6 +54,7 @@ import jakarta.servlet.*;
*
* @since 9.5.0
*/
[email protected]
class BasicEchoResource_JettyMicroservice_Test extends TestBase {
@Rest(mixins=BasicEchoResource.class, debug=@Debug("always"))
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
index 919cef2a4c..65ef6a7138 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
@@ -59,6 +59,7 @@ import org.springframework.test.annotation.*;
*
* @since 9.5.0
*/
[email protected]
@SpringBootTest(classes = BasicEchoResource_Springboot_Test.TestApp.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/RestPathsRuntimeOverride_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/RestPathsRuntimeOverride_Springboot_Test.java
index e6e51ba76a..d394fb86a4 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/RestPathsRuntimeOverride_Springboot_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/RestPathsRuntimeOverride_Springboot_Test.java
@@ -58,6 +58,7 @@ import org.springframework.context.*;
*
* @since 9.5.0
*/
[email protected]
@SuppressWarnings({
"unchecked" // ApplicationContext.getBeanProvider(Class) is
unchecked-bound in the Spring API.
})
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringBeanStore_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringBeanStore_Test.java
index 5dd8212afa..9acaed17e4 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringBeanStore_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringBeanStore_Test.java
@@ -30,6 +30,7 @@ import org.springframework.context.*;
/**
* Tests for {@link SpringBeanStore}.
*/
[email protected]
@SuppressWarnings({
"unchecked" // getBean(String, Class) invocation in test
})
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_SpringbootIntegration_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_SpringbootIntegration_Test.java
index 65592c5e65..49d622341d 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_SpringbootIntegration_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_SpringbootIntegration_Test.java
@@ -54,6 +54,7 @@ import org.springframework.test.context.*;
* minimal {@code @SpringBootConfiguration} class, no web environment, dirty
context after the
* class so subsequent tests don't see the bridge.
*/
[email protected]
@SpringBootTest(classes =
SpringEnvironmentPropertySource_SpringbootIntegration_Test.TestApp.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(properties = {
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_Test.java
index d9b12eb5a0..ce00bfbc35 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/springboot/SpringEnvironmentPropertySource_Test.java
@@ -49,6 +49,7 @@ import org.springframework.mock.env.*;
* context which now installs the bridge automatically via {@link
SpringBeanStore}'s constructor.
* This test keeps the focus on the bridge itself.
*/
[email protected]
@SuppressWarnings({
"java:S2094" // Test fixture / data class, no methods required.
})
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
index 457112bad0..21149081a3 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java
@@ -61,6 +61,7 @@ import jakarta.servlet.*;
*
* @since 9.5.0
*/
[email protected]
class BasicStaticFilesResource_JettyMicroservice_Test extends TestBase {
/** Test host: vanilla {@link BasicRestServlet} subclass with the
static-files mixin attached. */
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
index cc2e672ef2..566de728e5 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java
@@ -61,6 +61,7 @@ import org.springframework.test.annotation.*;
*
* @since 9.5.0
*/
[email protected]
@SpringBootTest(classes =
BasicStaticFilesResource_SpringbootMetaInf_Test.TestApp.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
index 4774378ae1..edf0055907 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java
@@ -62,6 +62,7 @@ import org.springframework.test.annotation.*;
*
* @since 9.5.0
*/
[email protected]
@SpringBootTest(classes =
BasicStaticFilesResource_Springboot_Test.TestApp.class,
webEnvironment = WebEnvironment.RANDOM_PORT)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/testing/annotations/JettyMicroserviceTest.java
b/juneau-utest/src/test/java/org/apache/juneau/testing/annotations/JettyMicroserviceTest.java
new file mode 100644
index 0000000000..a9dd6b690e
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/testing/annotations/JettyMicroserviceTest.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.testing.annotations;
+
+import java.lang.annotation.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Marker for container-backed Jetty microservice tests.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Tag("container")
+@Tag("jetty")
+public @interface JettyMicroserviceTest {}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/testing/annotations/SpringbootTest.java
b/juneau-utest/src/test/java/org/apache/juneau/testing/annotations/SpringbootTest.java
new file mode 100644
index 0000000000..0ff3f8bc07
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/testing/annotations/SpringbootTest.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.testing.annotations;
+
+import java.lang.annotation.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Marker for container-backed Spring Boot tests.
+ */
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Tag("container")
+@Tag("springboot")
+public @interface SpringbootTest {}
diff --git a/scripts/check-container-tags.py b/scripts/check-container-tags.py
new file mode 100644
index 0000000000..ff3428d373
--- /dev/null
+++ b/scripts/check-container-tags.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Guardrail for container-backed tests.
+
+Fails when a container-booting test class is missing @SpringbootTest or
+@JettyMicroserviceTest.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+SPRING_HINTS = (
+ r"@SpringBootTest\b",
+ r"@AutoConfigureMockMvc\b",
+ r"@ExtendWith\s*\(\s*SpringExtension\.class",
+ r"SpringApplication\.run\s*\(",
+)
+
+JETTY_HINTS = (
+ r"JettyMicroservice\.(?:create|builder)\s*\(",
+ r"Microservice\.(?:create|builder)\s*\(",
+ r"new\s+org\.eclipse\.jetty\..*?Server\s*\(",
+)
+
+TAG_MARKERS = (
+ "@SpringbootTest",
+ "@JettyMicroserviceTest",
+ "@org.apache.juneau.testing.annotations.SpringbootTest",
+ "@org.apache.juneau.testing.annotations.JettyMicroserviceTest",
+)
+
+
+def should_require_tag(content: str) -> bool:
+ return any(re.search(p, content) for p in (*SPRING_HINTS, *JETTY_HINTS))
+
+
+def has_container_tag(content: str) -> bool:
+ return any(marker in content for marker in TAG_MARKERS)
+
+
+def scan(repo_root: Path) -> list[Path]:
+ test_root = repo_root / "juneau-utest" / "src" / "test" / "java"
+ offenders: list[Path] = []
+ for path in sorted(test_root.rglob("*Test.java")):
+ content = path.read_text(encoding="utf-8")
+ if should_require_tag(content) and not has_container_tag(content):
+ offenders.append(path)
+ return offenders
+
+
+def main() -> int:
+ repo_root = Path(__file__).resolve().parent.parent
+ offenders = scan(repo_root)
+ if not offenders:
+ print("โ
Container tag guard passed.")
+ return 0
+
+ print("โ Container tag guard failed. The following tests appear to boot
containers but are missing a tag:")
+ for path in offenders:
+ rel = path.relative_to(repo_root)
+ print(f" - {rel}")
+ print("\nAdd @SpringbootTest or @JettyMicroserviceTest to each class.")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/push-timings.py b/scripts/push-timings.py
new file mode 100644
index 0000000000..eecd9525ab
--- /dev/null
+++ b/scripts/push-timings.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Report timing deltas from push timing JSONL history.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import statistics
+from collections import defaultdict
+from pathlib import Path
+
+
+def load_records(path: Path) -> list[dict]:
+ if not path.exists():
+ return []
+ rows = []
+ for line in path.read_text(encoding="utf-8").splitlines():
+ if not line.strip():
+ continue
+ try:
+ rows.append(json.loads(line))
+ except json.JSONDecodeError:
+ continue
+ return rows
+
+
+def fmt_seconds(value: float) -> str:
+ return f"{value:.1f}s"
+
+
+def fmt_delta(value: float) -> str:
+ sign = "+" if value >= 0 else ""
+ return f"{sign}{value * 100:.1f}%"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Print push timing regression
summary.")
+ parser.add_argument("--log", required=True, help="Path to timing JSONL")
+ parser.add_argument("--window", type=int, default=20, help="Rolling median
window per execution")
+ args = parser.parse_args()
+
+ log_path = Path(args.log).expanduser()
+ records = load_records(log_path)
+ if not records:
+ print("๐ Push-timing report: no timing history yet.")
+ return 0
+
+ latest_run = records[-1].get("run_id")
+ if not latest_run:
+ print("๐ Push-timing report: missing run metadata in latest record.")
+ return 0
+
+ latest_records = [r for r in records if r.get("run_id") == latest_run and
r.get("passed", True)]
+ if not latest_records:
+ print("๐ Push-timing report: latest run did not produce successful
timing rows.")
+ return 0
+
+ threshold = float(os.environ.get("JUNEAU_PUSH_TIMING_THRESHOLD", "0.05"))
+ print(f"๐ Push-timing report (last {args.window} runs on this branch):")
+
+ prior_by_key: dict[tuple[str, str], list[float]] = defaultdict(list)
+ for row in records:
+ if row.get("run_id") == latest_run:
+ continue
+ key = (row.get("module", "?"), row.get("execution", "?"))
+ if row.get("passed", True):
+ prior_by_key[key].append(float(row.get("wallclock_s", 0.0)))
+
+ for row in sorted(latest_records, key=lambda r: (r.get("module", ""),
r.get("execution", ""))):
+ module = row.get("module", "?")
+ execution = row.get("execution", "?")
+ wallclock = float(row.get("wallclock_s", 0.0))
+ key = (module, execution)
+ prior = prior_by_key.get(key, [])
+ if prior:
+ baseline = statistics.median(prior[-args.window:])
+ delta = (wallclock - baseline) / baseline if baseline > 0 else 0.0
+ print(f" {module}/{execution:<18}: {fmt_seconds(wallclock):>6}
(median {fmt_seconds(baseline)}, delta {fmt_delta(delta)})")
+ if abs(delta) > threshold:
+ print(
+ f" โ {module}/{execution} wall-clock
{fmt_seconds(wallclock)} is {fmt_delta(delta)} "
+ f"vs rolling median {fmt_seconds(baseline)} (threshold
{threshold * 100:.0f}%)"
+ )
+ else:
+ print(f" {module}/{execution:<18}: {fmt_seconds(wallclock):>6}
(first run, no baseline)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/push.py b/scripts/push.py
index ccb5695889..8d77ca8f0a 100755
--- a/scripts/push.py
+++ b/scripts/push.py
@@ -294,6 +294,27 @@ def check_upstream_changes(repo_dir):
return (False, f"Error checking upstream changes: {e}")
+def current_branch(repo_dir):
+ """Get current git branch name."""
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
+ cwd=repo_dir,
+ capture_output=True,
+ text=True,
+ check=True
+ )
+ return result.stdout.strip()
+ except Exception:
+ return "unknown"
+
+
+def timing_log_path(repo_dir):
+ """Out-of-repo branch-specific timing history location."""
+ branch = current_branch(repo_dir).replace("/", "__")
+ return Path.home() / ".cache" / "juneau-push-timings" / f"{branch}.jsonl"
+
+
def play_sound(success=True): # NOSONAR python:S3776 -- Cognitive complexity
is acceptable for this utility function
"""
Play a system sound to indicate success or failure.
@@ -412,7 +433,11 @@ Examples:
print(f" {step_num}. Prompt for PGP passphrase (dummy call)")
step_num += 1
if not args.skip_tests:
- print(f" {step_num}. Run tests: python3 scripts/test.py --full")
+ print(f" {step_num}. Verify container test tags: python3
scripts/check-container-tags.py")
+ step_num += 1
+ print(f" {step_num}. Run tests with timing capture: python3
scripts/test.py --full --timing-log
~/.cache/juneau-push-timings/<branch>.jsonl")
+ step_num += 1
+ print(f" {step_num}. Print timing deltas: python3
scripts/push-timings.py --log ~/.cache/juneau-push-timings/<branch>.jsonl")
step_num += 1
print(f" {step_num}. Build and install: mvn clean package install
-DskipTests")
step_num += 1
@@ -437,12 +462,25 @@ Examples:
# Step 1: Run tests (optional)
if not args.skip_tests:
+ check_container_tags = script_dir / "check-container-tags.py"
+ if check_container_tags.exists():
+ if not run_command(
+ [sys.executable, str(check_container_tags)],
+ f"๐ Step {step_num}: Checking container test tags...",
+ juneau_root
+ ):
+ print("\nโ Build process aborted due to missing container test
tags.")
+ play_sound(success=False)
+ return 1
+ step_num += 1
+
test_script = script_dir / 'test.py'
+ timing_file = timing_log_path(juneau_root)
if test_script.exists():
print(f"\n๐งช Step {step_num}: Running tests via test.py...")
try:
result = subprocess.run(
- [sys.executable, str(test_script), "--full"],
+ [sys.executable, str(test_script), "--full",
"--timing-log", str(timing_file)],
cwd=juneau_root,
check=False
)
@@ -465,6 +503,13 @@ Examples:
print("\nโ Build process aborted due to test failures.")
play_sound(success=False)
return 1
+ timing_report = script_dir / "push-timings.py"
+ if timing_report.exists():
+ run_command(
+ [sys.executable, str(timing_report), "--log",
str(timing_file)],
+ f"๐ Step {step_num}: Timing regression report...",
+ juneau_root
+ )
step_num += 1
else:
print(f"\nโญ๏ธ Step {step_num}: Skipping tests (--skip-tests flag)")
diff --git a/scripts/surefire-rollup.py b/scripts/surefire-rollup.py
new file mode 100644
index 0000000000..34f21a7af9
--- /dev/null
+++ b/scripts/surefire-rollup.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Roll up Surefire XML results into timing buckets.
+"""
+
+from __future__ import annotations
+
+import argparse
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass
+class BucketStats:
+ tests: int = 0
+ seconds: float = 0.0
+
+
+def parse_xml_dir(directory: Path) -> BucketStats:
+ stats = BucketStats()
+ if not directory.exists():
+ return stats
+ for xml_file in sorted(directory.glob("TEST-*.xml")):
+ root = ET.parse(xml_file).getroot()
+ stats.tests += int(root.attrib.get("tests", 0))
+ stats.seconds += float(root.attrib.get("time", 0.0))
+ return stats
+
+
+def source_for_class(module_dir: Path, class_name: str) -> Path | None:
+ if not class_name:
+ return None
+ parts = class_name.split(".")
+ if len(parts) < 2:
+ return None
+ return module_dir / "src" / "test" / "java" /
Path("/".join(parts)).with_suffix(".java")
+
+
+def source_content(module_dir: Path, class_name: str) -> str:
+ source = source_for_class(module_dir, class_name)
+ if source and source.exists():
+ return source.read_text(encoding="utf-8")
+ return ""
+
+
+def container_bucket(module_dir: Path, class_name: str) -> str:
+ content = source_content(module_dir, class_name)
+ if "SpringbootTest" in content:
+ return "container.springboot"
+ if "JettyMicroserviceTest" in content:
+ return "container.jetty"
+ lower = class_name.lower()
+ if "springboot" in lower:
+ return "container.springboot"
+ if "jetty" in lower or "microservice" in lower:
+ return "container.jetty"
+ return ""
+
+
+def parse_container_subbuckets(module_dir: Path, container_dir: Path) ->
dict[str, BucketStats]:
+ buckets: dict[str, BucketStats] = {
+ "container.springboot": BucketStats(),
+ "container.jetty": BucketStats(),
+ }
+ if not container_dir.exists():
+ return buckets
+ for xml_file in sorted(container_dir.glob("TEST-*.xml")):
+ root = ET.parse(xml_file).getroot()
+ class_name = root.attrib.get("name", "")
+ bucket = container_bucket(module_dir, class_name) or "container.jetty"
+ buckets[bucket].tests += int(root.attrib.get("tests", 0))
+ buckets[bucket].seconds += float(root.attrib.get("time", 0.0))
+ return buckets
+
+
+def parse_flat_reports(module_dir: Path, reports_root: Path):
+ core = BucketStats()
+ sub = {
+ "container.springboot": BucketStats(),
+ "container.jetty": BucketStats(),
+ }
+ for xml_file in sorted(reports_root.glob("TEST-*.xml")):
+ root = ET.parse(xml_file).getroot()
+ tests = int(root.attrib.get("tests", 0))
+ seconds = float(root.attrib.get("time", 0.0))
+ class_name = root.attrib.get("name", "")
+ content = source_content(module_dir, class_name)
+ bucket = container_bucket(module_dir, class_name)
+ if "SpringbootTest" in content:
+ sub["container.springboot"].tests += tests
+ sub["container.springboot"].seconds += seconds
+ elif "JettyMicroserviceTest" in content:
+ sub["container.jetty"].tests += tests
+ sub["container.jetty"].seconds += seconds
+ elif bucket.startswith("container."):
+ sub[bucket].tests += tests
+ sub[bucket].seconds += seconds
+ else:
+ core.tests += tests
+ core.seconds += seconds
+ container = BucketStats(
+ tests=sub["container.springboot"].tests + sub["container.jetty"].tests,
+ seconds=sub["container.springboot"].seconds +
sub["container.jetty"].seconds,
+ )
+ return core, container, sub
+
+
+def bar(seconds: float, max_seconds: float, width: int = 20) -> str:
+ if max_seconds <= 0:
+ return ""
+ filled = int((seconds / max_seconds) * width)
+ filled = max(1 if seconds > 0 else 0, min(width, filled))
+ return "โ" * filled
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Aggregate Surefire reports
by execution bucket.")
+ parser.add_argument("module", default="juneau-utest", nargs="?",
help="Module path (default: juneau-utest)")
+ args = parser.parse_args()
+
+ repo_root = Path(__file__).resolve().parent.parent
+ module_dir = repo_root / args.module
+ reports_root = module_dir / "target" / "surefire-reports"
+
+ core_dir = reports_root / "core"
+ container_dir = reports_root / "container"
+ if core_dir.exists() or container_dir.exists():
+ core = parse_xml_dir(core_dir)
+ container = parse_xml_dir(container_dir)
+ sub = parse_container_subbuckets(module_dir, container_dir)
+ else:
+ core, container, sub = parse_flat_reports(module_dir, reports_root)
+
+ rows = [
+ ("core", core.tests, core.seconds),
+ ("container.springboot", sub["container.springboot"].tests,
sub["container.springboot"].seconds),
+ ("container.jetty", sub["container.jetty"].tests,
sub["container.jetty"].seconds),
+ ("container", container.tests, container.seconds),
+ ]
+ max_seconds = max((seconds for _, _, seconds in rows), default=0.0)
+
+ print(f"{args.module}/")
+ for name, tests, seconds in rows:
+ print(f" {name:<20}: {bar(seconds, max_seconds)} ({tests} tests,
{seconds:.3f}s)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test.py b/scripts/test.py
index 2a38dc81b1..2aaf9b5294 100755
--- a/scripts/test.py
+++ b/scripts/test.py
@@ -18,128 +18,225 @@ Usage:
./scripts/test.py [options]
Options:
- --build-only, -b Only build (skip tests)
- --test-only, -t Only run tests (no build)
- --full, -f Clean build + run tests (default)
- --verbose, -v Show full Maven output
- --help, -h Show this help message
+ --build-only, -b Only build (skip tests)
+ --test-only, -t Only run tests (no build)
+ --full, -f Clean build + run tests (default)
+ --verbose, -v Show full Maven output
+ --no-container Exclude @Tag("container") tests
+ --timing-log <path> Append per-bucket timing JSONL records
+ --profile <module> Run one-shot JFR profile for module tests
+ --help, -h Show this help message
"""
+import argparse
+import json
+import re
import subprocess
import sys
-import os
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone
from pathlib import Path
+
def run_command(cmd, verbose=False):
- """Run a command and return exit code and full output."""
- script_dir = Path(__file__).parent
- project_root = script_dir.parent
-
- print(f"Running: {cmd}")
- print("-" * 80)
-
- if verbose:
- # Show full output
- result = subprocess.run(cmd, shell=True, cwd=str(project_root),
capture_output=True, text=True)
- print(result.stdout)
- print(result.stderr, file=sys.stderr)
- return result.returncode, result.stdout + result.stderr
- else:
- # Run command and capture all output, then show last lines
- result = subprocess.run(
- cmd,
- shell=True,
- cwd=str(project_root),
- capture_output=True,
- text=True
- )
- # Combine stdout and stderr
- output = result.stdout + result.stderr
- # Show last 50 lines
- lines = output.splitlines()
- if len(lines) > 50:
- print('\n'.join(lines[-50:]))
- else:
- print(output)
- return result.returncode, output
+ """Run a command and return exit code and full output."""
+ script_dir = Path(__file__).parent
+ project_root = script_dir.parent
+ print(f"Running: {cmd}")
+ print("-" * 80)
+ result = subprocess.run(cmd, shell=True, cwd=str(project_root),
capture_output=True, text=True)
+ output = result.stdout + result.stderr
+ if verbose:
+ print(result.stdout)
+ print(result.stderr, file=sys.stderr)
+ else:
+ lines = output.splitlines()
+ print("\n".join(lines[-50:]) if len(lines) > 50 else output)
+ return result.returncode, output
-def build(verbose=False):
- """Run Maven clean install without tests."""
- return run_command("mvn clean install -DskipTests", verbose)
-def test(verbose=False):
- """Run Maven tests."""
- return run_command("mvn test -Drat.skip=true", verbose)
+def git_value(args):
+ try:
+ result = subprocess.run(["git", *args],
cwd=str(Path(__file__).parent.parent), capture_output=True, text=True,
check=True)
+ return result.stdout.strip()
+ except Exception:
+ return "unknown"
+
def parse_test_results(output):
- """Parse Maven test output and extract failure/error counts."""
- import re
- # Look for the last occurrence of: [ERROR] Tests run: 25916, Failures: 0,
Errors: 12, Skipped: 1
- # This will be the total across all modules
- matches = list(re.finditer(r'\[ERROR\]\s+Tests
run:\s+(\d+),\s+Failures:\s+(\d+),\s+Errors:\s+(\d+)', output))
- if matches:
- # Use the last match (final total)
- match = matches[-1]
- total = int(match.group(1))
- failures = int(match.group(2))
- errors = int(match.group(3))
- return total, failures, errors
- return None, None, None
+ matches = list(re.finditer(r"\[ERROR\]\s+Tests
run:\s+(\d+),\s+Failures:\s+(\d+),\s+Errors:\s+(\d+)", output))
+ if matches:
+ match = matches[-1]
+ total = int(match.group(1))
+ failures = int(match.group(2))
+ errors = int(match.group(3))
+ return total, failures, errors
+ return None, None, None
+
+
+def parse_surefire_dir(directory: Path):
+ tests, seconds = 0, 0.0
+ if not directory.exists():
+ return tests, seconds
+ for xml_file in sorted(directory.glob("TEST-*.xml")):
+ root = ET.parse(xml_file).getroot()
+ tests += int(root.attrib.get("tests", 0))
+ seconds += float(root.attrib.get("time", 0.0))
+ return tests, seconds
+
+
+def container_bucket(module_dir: Path, class_name: str):
+ source = module_dir / "src" / "test" / "java" /
Path("/".join(class_name.split("."))).with_suffix(".java")
+ if source.exists():
+ content = source.read_text(encoding="utf-8")
+ if "SpringbootTest" in content:
+ return "container.springboot"
+ if "JettyMicroserviceTest" in content:
+ return "container.jetty"
+ return "container.springboot" if "springboot" in class_name.lower()
else "container.jetty"
+
+
+def parse_container_subbuckets(module_dir: Path, directory: Path):
+ buckets = {
+ "container.springboot": {"tests": 0, "seconds": 0.0},
+ "container.jetty": {"tests": 0, "seconds": 0.0},
+ }
+ if not directory.exists():
+ return buckets
+ for xml_file in sorted(directory.glob("TEST-*.xml")):
+ root = ET.parse(xml_file).getroot()
+ bucket = container_bucket(module_dir, root.attrib.get("name",
""))
+ buckets[bucket]["tests"] += int(root.attrib.get("tests", 0))
+ buckets[bucket]["seconds"] += float(root.attrib.get("time",
0.0))
+ return buckets
+
+
+def write_timing_log(path: Path, passed: bool):
+ path = path.expanduser()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ repo_root = Path(__file__).parent.parent
+ module = "juneau-utest"
+ module_dir = repo_root / module
+ reports_root = module_dir / "target" / "surefire-reports"
+
+ core_tests, core_seconds = parse_surefire_dir(reports_root / "core")
+ container_tests, container_seconds = parse_surefire_dir(reports_root /
"container")
+ container_sub = parse_container_subbuckets(module_dir, reports_root /
"container")
+
+ run_id =
f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{git_value(['rev-parse',
'--short', 'HEAD'])}"
+ base = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "run_id": run_id,
+ "branch": git_value(["rev-parse", "--abbrev-ref", "HEAD"]),
+ "commit": git_value(["rev-parse", "--short", "HEAD"]),
+ "module": module,
+ "passed": passed,
+ }
+ rows = [
+ {**base, "execution": "core", "wallclock_s": core_seconds,
"test_count": core_tests},
+ {**base, "execution": "container", "wallclock_s":
container_seconds, "test_count": container_tests},
+ {
+ **base,
+ "execution": "container.springboot",
+ "wallclock_s":
container_sub["container.springboot"]["seconds"],
+ "test_count":
container_sub["container.springboot"]["tests"],
+ },
+ {
+ **base,
+ "execution": "container.jetty",
+ "wallclock_s":
container_sub["container.jetty"]["seconds"],
+ "test_count": container_sub["container.jetty"]["tests"],
+ },
+ ]
+ with path.open("a", encoding="utf-8") as f:
+ for row in rows:
+ f.write(json.dumps(row) + "\n")
+ print(f"๐ Timing metrics appended to {path}")
+
+
+def build(verbose=False):
+ return run_command("mvn clean install -DskipTests", verbose)
+
+
+def test(verbose=False, no_container=False):
+ cmd = "mvn test -Drat.skip=true"
+ if no_container:
+ cmd += " -DexcludedGroups=container"
+ return run_command(cmd, verbose)
+
+
+def profile(module, verbose=False):
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
+ profile_dir = Path("target/profile-results")
+ profile_dir.mkdir(parents=True, exist_ok=True)
+ safe_module = module.replace("/", "-")
+ output_file = profile_dir / f"{safe_module}-{ts}.jfr"
+ argline =
f"-XX:StartFlightRecording=filename={output_file},settings=profile,dumponexit=true"
+ cmd = f"mvn test -pl {module} -Drat.skip=true -DargLine='{argline}'"
+ code, out = run_command(cmd, verbose)
+ if code == 0:
+ print(f"\nโ
JFR profile captured at {output_file}")
+ return code, out
+
def main(): # NOSONAR python:S3776 -- Cognitive complexity is acceptable for
this main function
- args = sys.argv[1:]
-
- # Parse arguments
- build_only = False
- test_only = False
- full = True
- verbose = False
-
- for arg in args:
- if arg in ['--help', '-h']:
- print(__doc__)
- return 0
- elif arg in ['--build-only', '-b']:
- build_only = True
- full = False
- elif arg in ['--test-only', '-t']:
- test_only = True
- full = False
- elif arg in ['--full', '-f']:
- full = True
- elif arg in ['--verbose', '-v']:
- verbose = True
- else:
- print(f"Unknown option: {arg}")
- print(__doc__)
- return 1
-
- # Execute commands
- exit_code = 0
-
- if build_only or full:
- exit_code, output = build(verbose)
- if exit_code != 0:
- print("\nโ Build failed!")
- return exit_code
- print("\nโ
Build succeeded!")
-
- if test_only or full:
- if full:
- print("\n" + "=" * 80)
- exit_code, output = test(verbose)
- if exit_code != 0:
- # Try to parse test results
- _, failures, errors = parse_test_results(output)
- if failures is not None and errors is not None:
- failed_count = failures + errors
- print(f"\nโ Tests failed! ({failed_count} failed: {failures}
failures, {errors} errors)")
- else:
- print("\nโ Tests failed!")
- return exit_code
- print("\nโ
Tests passed!")
-
- return exit_code
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument("--build-only", "-b", action="store_true")
+ parser.add_argument("--test-only", "-t", action="store_true")
+ parser.add_argument("--full", "-f", action="store_true")
+ parser.add_argument("--verbose", "-v", action="store_true")
+ parser.add_argument("--no-container", action="store_true")
+ parser.add_argument("--timing-log")
+ parser.add_argument("--profile")
+ parser.add_argument("--help", "-h", action="store_true")
+ args, unknown = parser.parse_known_args()
+ if args.help:
+ print(__doc__)
+ return 0
+ if unknown:
+ print(f"Unknown option(s): {' '.join(unknown)}")
+ print(__doc__)
+ return 1
+
+ build_only = args.build_only
+ test_only = args.test_only
+ full = args.full or not (build_only or test_only or args.profile)
+ verbose = args.verbose
+
+ if args.profile:
+ exit_code, _ = profile(args.profile, verbose)
+ return exit_code
+
+ if build_only and test_only:
+ print("Cannot combine --build-only and --test-only")
+ return 1
+
+ exit_code = 0
+ last_test_output = ""
+ if build_only or full:
+ exit_code, _ = build(verbose)
+ if exit_code != 0:
+ print("\nโ Build failed!")
+ return exit_code
+ print("\nโ
Build succeeded!")
+
+ if test_only or full:
+ if full:
+ print("\n" + "=" * 80)
+ exit_code, last_test_output = test(verbose,
no_container=args.no_container)
+ if exit_code != 0:
+ _, failures, errors =
parse_test_results(last_test_output)
+ if failures is not None and errors is not None:
+ print(f"\nโ Tests failed! ({failures + errors}
failed: {failures} failures, {errors} errors)")
+ else:
+ print("\nโ Tests failed!")
+ else:
+ print("\nโ
Tests passed!")
+ if args.timing_log:
+ write_timing_log(Path(args.timing_log),
passed=(exit_code == 0))
+ if exit_code != 0:
+ return exit_code
+ return exit_code
if __name__ == '__main__':
sys.exit(main())
diff --git a/todo/FINISHED-105-container-tag-perf-tracking.md
b/todo/FINISHED-105-container-tag-perf-tracking.md
new file mode 100644
index 0000000000..4277e4043c
--- /dev/null
+++ b/todo/FINISHED-105-container-tag-perf-tracking.md
@@ -0,0 +1,54 @@
+# FINISHED-105 โ Container-test tag isolation + perf-regression tracking +
profiling pass
+
+Source plan:
[`TODO-105-container-tag-perf-tracking.md`](TODO-105-container-tag-perf-tracking.md).
+
+## Summary
+
+TODO-105 shipped the regression-detection and test-bucketing infrastructure
requested after the
+FINISHED-79 slowdown incident. The landing keeps `juneau-utest` as a single
Maven module while
+making container-heavy tests explicit and measurable.
+
+## What landed
+
+1. **Container-tag meta-annotations and adoption.**
+ - Added `@SpringbootTest` and `@JettyMicroserviceTest` in
+ `juneau-utest/src/test/java/org/apache/juneau/testing/annotations/`.
+ - Tagged the inventoried container-booting test classes (Spring Boot +
Jetty/Microservice)
+ so they can be filtered as `container`, `springboot`, and `jetty`.
+
+2. **Dual Surefire execution buckets in `juneau-utest/pom.xml`.**
+ - Core execution excludes `container` and writes to
+ `target/surefire-reports/core/`.
+ - Container execution includes `container` and writes to
+ `target/surefire-reports/container/`.
+ - JaCoCo remains a single `.exec` workflow (no coverage-script changes
required).
+
+3. **Container-tag discipline guard in push flow.**
+ - Added `scripts/check-container-tags.py`.
+ - Wired the guard into `scripts/push.py` before tests, so untagged
container-booting tests
+ fail fast during `/push`.
+
+4. **Per-execution push timing capture and reporting.**
+ - Added `scripts/test.py --timing-log ...` plumbing to write JSONL records.
+ - Added `scripts/push-timings.py` to print rolling-median timing deltas per
execution.
+ - `scripts/push.py` now captures and prints timing report output after
tests.
+
+5. **Surefire XML rollup utility.**
+ - Added `scripts/surefire-rollup.py` for bucketed wall-clock and test-count
summaries over
+ `core`, `container`, `container.springboot`, and `container.jetty`.
+
+6. **One-shot profiling support.**
+ - Added `scripts/test.py --profile <module>` with JFR output under
+ `target/profile-results/`.
+ - Profiling findings are intentionally tracked as follow-on optimization
work in TODO-999,
+ not implemented in this landing.
+
+## Scope boundaries respected
+
+- `juneau-utest` was not split into new Maven modules.
+- No blocking policy was added for timing regressions; reporting remains
warning-only.
+- Existing coverage scripts continue to use `juneau-utest/target/jacoco.exec`.
+
+## Follow-ups
+
+- Optimization candidates discovered via profiling remain queued under
TODO-999.
diff --git a/todo/FINISHED.md b/todo/FINISHED.md
index 7562f78028..780f1d522e 100644
--- a/todo/FINISHED.md
+++ b/todo/FINISHED.md
@@ -12,6 +12,8 @@ In completion order, oldest-to-newest within the foundations
+ mixin family + re
### Phase D โ SVL overhaul (newest)
+- **TODO-105** โ Container-test perf-signal hardening landed: `juneau-utest`
now has explicit container-tag meta-annotations (`@SpringbootTest`,
`@JettyMicroserviceTest`) on the 22 container-booting classes, Surefire
executes core and container buckets separately, and
`scripts/check-container-tags.py` is wired as a pre-push guard so new container
tests cannot silently drift into the core timing bucket. `/push` now records
per-execution timing history in `~/.cache/juneau-push-timings/<bra [...]
+
- **TODO-102 + TODO-103** (joint landing) โ `#{...}` scripting syntax +
`VarTemplate` compiled-template API. Combined infrastructure (single
recursive-descent tokenizer/compiler + segment-array machinery) underpins both
features so the tokenizer was built once and reused. **Hard break:** 11
single-purpose transformation `Var` classes deleted (`IfVar`, `SwitchVar`,
`CoalesceVar`, `NotEmptyVar`, `PatternMatchVar`, `PatternReplaceVar`,
`PatternExtractVar`, `UpperCaseVar`, `LowerCaseVar`, `L [...]
### Phase C2 ancillary โ bundled-mixin SVL retrofit
diff --git a/todo/TODO-105-container-tag-perf-tracking.md
b/todo/TODO-105-container-tag-perf-tracking.md
new file mode 100644
index 0000000000..a7b6eb1a18
--- /dev/null
+++ b/todo/TODO-105-container-tag-perf-tracking.md
@@ -0,0 +1,340 @@
+# TODO-105: Container-test tag isolation + perf-regression tracking +
profiling pass
+
+Source: TODO.md headline bullet expanded 2026-05-26; pivoted same day from a
module-split approach to a JUnit-5 tag-isolation approach after a revisit
showed the tag mechanism solves the same goal without complicating the
single-`.exec` JaCoCo workflow.
+
+## Goal
+
+Build the automatic regression-detection seam the FINISHED-79 incident proved
we need (a `RestContext.build()` โ `Settings.get().addSource(...)` bridge
leaked `ConfigPropertySource` instances into a process-wide singleton via
`MockRestClient`'s static `RestContext` cache, blowing the utest suite from
~35s to ~560s; caught only by a human noticing the slowdown), then take a
one-shot profiling pass to file optimization candidates into TODO-999.
+
+Four sub-deliverables:
+
+- **(a) Container-test tag isolation.** Introduce JUnit-5 meta-annotations
(`@SpringbootTest`, `@JettyMicroserviceTest`) bundling `@Tag("container")` + a
container-type sub-tag. Annotate the ~22 container-booting classes. Add a dual
`<execution>` block to Surefire so `mvn test` runs core-only AND container
suites separately with their own `reportsDirectory`. Single Maven module,
single JaCoCo `.exec`, no coverage-workflow churn.
+- **(b) Per-execution timing capture in `/push`.** Enrich `scripts/push.py` so
every `/push` appends a per-Surefire-execution wall-clock + test-count record
to a `.push-timings.jsonl` log; add a reporter that flags >20% deltas from the
rolling median, separately per execution (core vs container).
+- **(c) One-shot profiling pass.** `scripts/test.py --profile <module>`
plumbing via JFR (or async-profiler). Run over the heaviest modules; file
findings into
[`TODO-999-final-prerelease-cleanup.md`](TODO-999-final-prerelease-cleanup.md)'s
"Working notes" section.
+- **(d) Surefire XML rollup with container-type bucketing.** Python script
reads `target/surefire-reports/core/` + `target/surefire-reports/container/`
(the dual-execution output dirs from deliverable a) and produces a per-bucket
wall-clock table (`core` / `container.springboot` / `container.jetty`) per push.
+
+## Implementation status (2026-05-26)
+
+- [x] Completed and archived as
[`FINISHED-105-container-tag-perf-tracking.md`](FINISHED-105-container-tag-perf-tracking.md).
+- [x] Phase 1 implemented: container meta-annotations added, 22 inventoried
classes tagged, dual-execution Surefire split in `juneau-utest/pom.xml`, and
`scripts/check-container-tags.py` wired into `scripts/push.py`.
+- [x] Phase 2 implemented: `scripts/test.py --timing-log` writes per-execution
JSONL metrics to `~/.cache/juneau-push-timings/<branch>.jsonl`;
`scripts/push.py` runs `scripts/push-timings.py` and always prints timing
numbers with >5% warning banners.
+- [x] Phase 3 implemented: `scripts/surefire-rollup.py` aggregates `core`,
`container`, `container.springboot`, and `container.jetty` buckets from
Surefire XML.
+- [x] Phase 4 support implemented: `scripts/test.py --profile <module>` emits
one-shot JFR recordings under `target/profile-results/`.
+
+## Why
+
+- **The FINISHED-79 incident**: the perf regression sat in master for an
unknown window before a human noticed `./scripts/test.py` was suddenly slow. No
automated detection. The root cause turned out to be a process-wide singleton
interaction (`MockRestClient`'s static `RestContext` cache +
`Settings.get().addSource(...)` from `RestContext.build()`), invisible to
per-test timing because *every* test slowed down proportionally.
+- **Today's `scripts/push.py` captures pass/fail only** โ no wall-clock, no
per-module breakdown. The signal needed to catch FINISHED-79-class regressions
exists in Surefire output but is never persisted.
+- **The container-booting tests are a small, slow tail.** Spring Boot context
init + Jetty bind-port-and-handshake takes seconds per test class; a regression
in core marshall code that's amortized over ~1237 fast in-process tests gets
visually washed out by the ~22 slow container tests in the combined timing.
**Isolating** the slow tail (without physically moving its classes) lets the
core suite's wall-clock be a sensitive signal again.
+- **`MockRest`-based tests are NOT container tests.** They run in-process
(Servlet API surface mocked via `juneau-rest-mock`), no socket bind, no servlet
container. They stay in the core bucket.
+
+## Why tags instead of a Maven-module split
+
+Considered both. Comparison summary that drove the pivot:
+
+| Concern | Module split | JUnit `@Tag` (this plan) |
+|---|---|---|
+| JaCoCo coverage | Two `.exec` files; must merge via `mvn jacoco:merge` or
change `scripts/coverage.py` | **Unchanged** โ single `.exec`,
`scripts/coverage.py` untouched |
+| Maven dep graph | New POM, repackage shared test utils, possibly extract a
`juneau-utest-common` | **Unchanged** โ single module |
+| Classpath / IDE config | Two test source roots, two run configs |
**Unchanged** โ single source root |
+| Eclipse Run-As JUnit ergonomics | Pick the right project before launching |
Tags-tab include/exclude works in any test launcher |
+| Per-bucket timing isolation | Surefire reports naturally bucketed by module
| Two Surefire `<execution>` blocks โ bucketed `reportsDirectory` |
+| Initial migration cost | ~22 file moves + new POM + dep wiring | ~22
one-line annotation additions |
+| Reversibility | Painful to undo | Trivial โ delete the annotations |
+| Future drift risk | Module boundary enforces physically | Soft โ relies on a
discipline-guard script (`scripts/check-container-tags.py`) |
+
+The discipline-drift risk is the only real cost; closing it with a guard
script is sub-second and runs in `scripts/push.py` as a precondition.
Everything else is strictly cheaper.
+
+## Non-goals
+
+- **Not splitting `juneau-utest` into multiple Maven modules.** Single module
stays single module. JaCoCo `.exec`, `scripts/coverage.py`, dependency graph
all untouched.
+- **Not implementing the optimization fixes** surfaced by the Phase 4
profiling pass. Those land via TODO-999's release-cleanup batch. TODO-105 ships
the infrastructure; TODO-999 ships the fixes. Hard scope boundary โ this PR
must not grow optimization patches.
+- **Not changing test-framework versions or test idioms.** Annotate existing
tests, don't rewrite them.
+- **Not splitting `org.apache.juneau.rest.*` MockRest tests out** of the core
bucket. They stay in `!container`. MockRest is in-process.
+- **Not splitting REST-client tests** (rest/client + http/classic) out of the
core bucket. They use MockRest, in-process.
+- **Not gating `/push` on a regression flag.** The reporter is warning-only. A
>20% delta prints a banner; it does not block the commit. False positives kill
the signal otherwise.
+- **Not building a long-term timing-trend dashboard.** Just the rolling-median
delta detection.
+
+## Current test landscape (inventory baseline, captured 2026-05-26 at SHA
`ba158b21ebf2589df375f0ff6d0defdb0ce85282`)
+
+Total `*Test.java` under `juneau-utest/src/test/java`: **1259**.
+
+### Container-bound tests (the annotation scope)
+
+**Spring Boot (10 classes using `@SpringBootTest` or equivalent boot):**
+
+| Class | Naming form |
+|---|---|
+| `rest/convention/BasicVersionResource_Springboot_Test.java` | clean suffix |
+| `rest/ops/BasicEchoResource_Springboot_Test.java` | clean suffix |
+| `rest/staticfiles/BasicStaticFilesResource_Springboot_Test.java` | clean
suffix |
+| `rest/docs/BasicApiDocs_Springboot_Test.java` | clean suffix |
+| `rest/springboot/RestPathsRuntimeOverride_Springboot_Test.java` | clean
suffix |
+| `rest/staticfiles/BasicStaticFilesResource_SpringbootMetaInf_Test.java` |
**drift** โ `_SpringbootMetaInf_Test` |
+|
`rest/springboot/SpringEnvironmentPropertySource_SpringbootIntegration_Test.java`
| **drift** โ `_SpringbootIntegration_Test` |
+| `rest/docs/BasicApiDocs_Springboot_MultiOpenApiProvider_Test.java` |
**drift** โ `_Springboot` mid-name, qualifier suffix |
+| `rest/springboot/SpringEnvironmentPropertySource_Test.java` | **drift** โ
`@SpringBootTest` annotation, no `Springboot` in class name |
+| `rest/springboot/SpringBeanStore_Test.java` | **drift** โ `@SpringBootTest`
annotation, no `Springboot` in class name |
+
+(5 clean / 5 drift.)
+
+**JettyMicroservice / Microservice (12 classes booting embedded Jetty via
`JettyMicroservice` or `Microservice` builder):**
+
+| Class | Naming form |
+|---|---|
+| `rest/convention/BasicVersionResource_JettyMicroservice_Test.java` | clean
suffix |
+| `rest/ops/BasicEchoResource_JettyMicroservice_Test.java` | clean suffix |
+| `rest/staticfiles/BasicStaticFilesResource_JettyMicroservice_Test.java` |
clean suffix |
+| `rest/docs/BasicApiDocs_JettyMicroservice_Test.java` | clean suffix |
+| `microservice/Microservice_Builder_Test.java` | **drift** โ boots,
class-name-as-SUT convention |
+| `microservice/Microservice_Inject_Test.java` | **drift** โ same |
+| `microservice/Microservice_Listener_Fanout_Test.java` | **drift** โ same |
+| `microservice/Microservice_OverridingBeanStore_Test.java` | **drift** โ same
|
+| `microservice/Microservice_PushPopOverlay_Test.java` | **drift** โ same |
+| `microservice/jetty/JettyConfiguration_Test.java` | **drift** โ same |
+| `microservice/jetty/Rest_Paths_Test.java` | **drift** โ same |
+| `microservice/jetty/RestPathsRuntimeOverride_JettyMount_Test.java` |
**drift** โ `_JettyMount_Test` |
+
+(4 clean / 8 drift.)
+
+**Microservice/* tests confirmed NOT container-bound (stay in `!container`):**
`BasicMicroserviceListener_Test`, `LogConfig_Test`, `LogParser_Test`,
`LogEntryFormatter_Test`, `ConsoleCommand_Test`, `LogsResource_Action_Test`,
`LogsResource_PathTraversal_Test`, `DirectoryResource_Action_Test`,
`DirectoryResource_PathTraversal_Test`, `microservice/jetty/JettyLogger_Test`.
+
+### Annotation-scope summary
+
+- **Classes that get `@SpringbootTest`: 10.**
+- **Classes that get `@JettyMicroserviceTest`: 12.**
+- **Classes left untagged (default `!container` bucket): ~1237.**
+- **Naming-convention drift: 13 of 22** (59%) โ those classes carry container
semantics but don't follow the `_<Container>_Test.java` suffix. OQ #8 asks
whether to rename them as a one-time consistency pass.
+
+### Push / test / coverage script integration seams
+
+- **`scripts/push.py`** runs `python3 scripts/test.py --full` once (currently
lines 442-468) and inspects only the exit code. The natural insertion points
for deliverable (b) and the discipline guard are *before* and *after* the test
invocation respectively โ `check-container-tags.py` runs before tests as a
precondition; the timing-log writer runs after tests complete.
+- **`scripts/test.py`** is the natural home for `--profile <module>`
(deliverable c) and `--timing-log <path>` (deliverable b). It already shells
`mvn test -Drat.skip=true`; the `--profile` variant becomes `mvn test -pl
<module> -Drat.skip=true -DargLine='-XX:StartFlightRecording=...'`. The seam is
the existing `run_command(...)` helper.
+- **`scripts/coverage.py`** reads exactly one `.exec` file:
`juneau-utest/target/jacoco.exec`. **Unchanged by this plan.** The dual
Surefire `<execution>` writes to the same `.exec` (JaCoCo appends across
executions by default).
+
+## Meta-annotation design
+
+Two sibling typed meta-annotations under
`juneau-utest/src/test/java/org/apache/juneau/testing/annotations/`:
+
+```java
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Tag("container")
+@Tag("springboot")
+public @interface SpringbootTest {}
+
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+@Tag("container")
+@Tag("jetty")
+public @interface JettyMicroserviceTest {}
+```
+
+Naming rationale: matches the existing `_Springboot_Test` /
`_JettyMicroservice_Test` class-suffix convention so the annotation reads as a
natural alias for the class name. The two-annotation pattern (not a
parameterized enum) is forced by Java: a custom annotation with an enum
`value()` cannot dynamically derive a second `@Tag` from its argument, so the
sub-tag must be statically declared.
+
+Adoption pattern:
+
+```java
+@SpringbootTest
+public class BasicVersionResource_Springboot_Test { ... }
+
+@JettyMicroserviceTest
+public class BasicEchoResource_JettyMicroservice_Test { ... }
+```
+
+JUnit-5 tag-discovery (`AnnotationUtils.findRepeatableAnnotations(testClass,
Tag.class)`) walks meta-annotation hierarchies, so `@SpringbootTest` matches a
filter on `container`, `springboot`, or `container & springboot` identically to
a direct `@Tag` would. Confirmed compatibility:
+
+- **Surefire** (`<groups>` / `<excludedGroups>`).
+- **Eclipse Run-As โ JUnit Test โ Tags tab** (passes the expression through to
the JUnit-platform `Launcher` directly).
+- **IntelliJ Run Configurations โ Test kind: Tags** (same mechanism).
+
+### Developer ergonomics enabled
+
+CLI:
+- `mvn test` โ runs both executions (core + container).
+- `mvn test -DexcludedGroups=container` โ core-only (fast inner loop,
~30-something seconds today).
+- `mvn test -Dgroups=container` โ container-only.
+- `mvn test -Dgroups=springboot` โ just Spring Boot tests.
+- `mvn test -Dgroups=jetty` โ just Jetty Microservice tests.
+- `mvn test -Dgroups='container & jetty'` โ boolean expression.
+- `./scripts/test.py --no-container` โ convenience wrapper (sets
`-DexcludedGroups=container`).
+
+Eclipse: Run As โ JUnit Test โ Run Configurations โ Tags tab โ enter
`container` in Exclude (or any expression). Re-runs honor the saved filter.
+
+IntelliJ: Run Configurations โ Test kind: Tags โ enter `container` in Exclude.
+
+## Resolved design decisions
+
+_All eight OQAs answered 2026-05-26. Verbatim originals preserved below for
archive value โ each carries an inline **Resolved** line._
+
+1. **Meta-annotation naming.** `@SpringbootTest` + `@JettyMicroserviceTest`
(matches existing class-suffix convention, longer) vs `@Springboot` + `@Jetty`
(shorter, but `@Jetty` is ambiguous โ could be misread as a generic Jetty
marker rather than "this test boots `JettyMicroservice`") vs
`@SpringbootContainerTest` + `@JettyMicroserviceContainerTest` (explicit but
verbose).
+ - **Resolved 2026-05-26**: `@SpringbootTest` + `@JettyMicroserviceTest`
(default, suffix-aligned).
+2. **Push-timings log location.** Three flavors:
+ - **`target/.push-timings.jsonl`** (gitignored under existing `**/target/`
rule) โ per-developer, no sharing.
+ - **`scripts/.push-timings.jsonl`** (checked in) โ team-shared rolling
history; every push grows the log โ noisy diffs + merge conflicts.
+ - **`~/.cache/juneau-push-timings/<branch>.jsonl`** (out-of-repo) โ
per-developer, branch-keyed, no repo footprint.
+
+ The "team-shared" flavor catches a regression *another* developer
introduces; the per-developer flavors only catch regressions you yourself just
pushed.
+ - **Resolved 2026-05-26**: out-of-repo
(`~/.cache/juneau-push-timings/<branch>.jsonl`).
+3. **Regression-detection threshold.** Originally proposed >20% delta from
rolling median.
+ - Per-execution (core vs container, separately) or global?
+ - Adjustable knob?
+ - Warn-only on first delta, escalate on N consecutive?
+ - What threshold value is reasonable?
+ - **Resolved 2026-05-26**:
+ - **Threshold = 5%** (default `JUNEAU_PUSH_TIMING_THRESHOLD=0.05`).
Tighter than the originally-proposed 20% because catching subtler regressions
early is the goal โ even a 5% perf creep across a few PRs is signal worth
surfacing.
+ - **Per-execution** (core vs container, independent thresholds).
+ - **Warn-only always.** No escalation tier for v1.
+ - **Always print the numbers** at the end of every `/push`, regardless of
whether the threshold is breached. The threshold only controls whether a โ
warning banner is emitted ON TOP of the numbers. Per-execution timing report
format:
+ ```
+ ๐ Push-timing report (last 20 runs on this branch):
+ juneau-utest/core : 28.4s (median 27.9s, delta +1.8%)
+ juneau-utest/container: 18.6s (median 18.2s, delta +2.2%)
+ ```
+ And when threshold breached:
+ ```
+ ๐ Push-timing report (last 20 runs on this branch):
+ juneau-utest/core : 41.2s (median 27.9s, delta +47.7%)
+ โ juneau-utest/core wall-clock 41.2s is +47.7% above rolling median
27.9s (last 20 runs, threshold 5%)
+ juneau-utest/container: 18.6s (median 18.2s, delta +2.2%)
+ ```
+4. **Profiling tooling pick.**
+ - **JFR** โ bundled with JDK, no install, slightly less detail on native
frames, JSON/binary `.jfr` viewer in JDK Mission Control.
+ - **async-profiler** โ better flame graphs, native installer required
(`brew install async-profiler` on macOS).
+ - **Resolved 2026-05-26**: JFR. Drop in async-profiler ad hoc if the v1
pass surfaces a native-frame-shaped problem JFR can't see clearly.
+5. **Profiling output destination.**
`target/profile-results/<module>-<ts>.jfr` (gitignored under `**/target/`) vs
upload to a shared store.
+ - **Resolved 2026-05-26**: local-only
(`target/profile-results/<module>-<ts>.jfr`).
+6. **Sequencing relative to TODO-94a/b/c** (auth-filter framework). Original
plan said "suggested before TODO-94a/b/c land" so the auth-filter PRs land with
regression-tracking in place. The narrower tag-based scope makes that
sequencing low-cost โ TODO-105 is small enough to land in days.
+ - **Resolved 2026-05-26**: before TODO-94a/b/c. Update TODO.md execution
order accordingly when TODO-105 lands (TODO-105 already sits earlier in Phase E
than TODO-94 in Phase F, so the existing order already reflects this).
+7. **Container-detection heuristics in `check-container-tags.py`.** The guard
script needs to identify a "container-booting test" without the tag. Proposed
signals (the script flags any class that matches one but is not tagged):
+ - Class has `@SpringBootTest` annotation (any of its forms โ
`@SpringBootTest`, `@AutoConfigureMockMvc`, etc.).
+ - Class has `@ExtendWith(SpringExtension.class)`.
+ - Class body or any nested class calls `JettyMicroservice.create()` /
`JettyMicroservice.builder()`.
+ - Class body calls `Microservice.create()` / `Microservice.builder()`
(excluding the legitimate `microservice/Microservice_*_Test` cases which
actually want the tag).
+ - Class `extends` a known container-boot base class (none today, but TBD
list).
+ - **Resolved 2026-05-26**: heuristic list above accepted as-is. Phase 1
also adds raw `org.springframework.boot.SpringApplication.run(...)` and raw
`org.eclipse.jetty.*.Server(...)` instantiation patterns to the scan to
future-proof against bypass.
+8. **Drift-cleanup rename pass.** Should Phase 1 also rename the 13
drift-cased classes to consistent `_Springboot_Test` /
`_JettyMicroservice_Test` suffixes (for IDE-grep friendliness + signaling
consistency), or leave the names as-is since the tag is the source of truth and
renaming touches blame history?
+ - **Resolved 2026-05-26**: leave names as-is. The tag is the source of
truth; drift-rename can land as a TODO-999 cleanup item if desired.
+
+## Phases
+
+### Phase 1 โ Tag introduction & adoption (deliverable a)
+
+1. **Create the meta-annotations** under
`juneau-utest/src/test/java/org/apache/juneau/testing/annotations/`:
`SpringbootTest.java`, `JettyMicroserviceTest.java`.
+2. **Annotate the 22 inventoried classes** with the appropriate
meta-annotation. Mechanical โ one line per class.
+3. **Add `scripts/check-container-tags.py`** โ discipline guard. Scans
`juneau-utest/src/test/java/**/*Test.java`, applies the OQ #7 heuristics to
identify container-booting classes, asserts each carries `@SpringbootTest` or
`@JettyMicroserviceTest`. Exits non-zero on a mismatch with a per-file list.
Sub-second runtime.
+4. **Configure dual-execution Surefire** in `juneau-utest/pom.xml`:
+ ```xml
+ <plugin>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-test</id>
+ <configuration>
+ <excludedGroups>container</excludedGroups>
+
<reportsDirectory>${project.build.directory}/surefire-reports/core</reportsDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>container-test</id>
+ <phase>test</phase>
+ <goals><goal>test</goal></goals>
+ <configuration>
+ <groups>container</groups>
+
<reportsDirectory>${project.build.directory}/surefire-reports/container</reportsDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ ```
+ Both `<execution>`s run during the `test` phase. The `reportsDirectory`
separation produces naturally bucketed Surefire output for deliverable (d).
+5. **Wire `scripts/check-container-tags.py` into `scripts/push.py`** as a
precondition (before the test step). Failed check blocks the push with a clear
error message and a per-class hint (`Add @SpringbootTest to <class>`).
+6. **Verify** โ `./scripts/test.py --full` is green; `mvn test
-DexcludedGroups=container` runs only the core suite; `mvn test
-Dgroups=container` runs only the container suite; `mvn test
-Dgroups=springboot` runs only the 10 Spring Boot classes. Eclipse Run-As โ
JUnit with `container` in the Tags exclude list runs only the core suite.
+
+**Acceptance:** all 22 container-bound classes tagged;
`scripts/check-container-tags.py` passes (and fails appropriately on a
fabricated unTagged-but-container-booting test); dual-execution Surefire
produces two `reportsDirectory` outputs; `scripts/coverage.py` produces
identical aggregate coverage figures to pre-tag.
+
+### Phase 2 โ Push-timing infrastructure (deliverable b)
+
+1. **`scripts/test.py --timing-log <path>` flag.** When set, captures
per-execution wall-clock and writes a JSONL record. Maven's Reactor Summary
prints per-module times; Surefire's per-execution times come from the
`target/surefire-reports/<dir>/TEST-*.xml` files (sum the `<testsuite
time="โฆ">` across the bucket dir). Record shape:
+ ```json
+ {"ts": "...", "branch": "...", "commit": "...", "module": "juneau-utest",
"execution": "core", "wallclock_s": 28.4, "test_count": 1237, "passed": true}
+ {"ts": "...", "branch": "...", "commit": "...", "module": "juneau-utest",
"execution": "container", "wallclock_s": 18.6, "test_count": 22, "passed": true}
+ ```
+2. **`scripts/push-timings.py` reporter.** Reads the last N records (default
20) from the JSONL log, computes per-`(module, execution)` rolling median,
compares the just-appended record's `wallclock_s` against the median.
+ - **Always print the numbers** โ every push prints a `๐ Push-timing report`
block with one row per `(module, execution)`, showing current wall-clock,
rolling median, and percent delta. Format per OQ #3 resolution:
+ ```
+ ๐ Push-timing report (last 20 runs on this branch):
+ juneau-utest/core : 28.4s (median 27.9s, delta +1.8%)
+ juneau-utest/container: 18.6s (median 18.2s, delta +2.2%)
+ ```
+ - **Warning banner only when threshold breached** โ if `|current - median|
/ median > threshold` (default `0.05` per OQ #3, env-var-tunable as
`JUNEAU_PUSH_TIMING_THRESHOLD`), inject a โ banner line immediately after the
offending row:
+ ```
+ juneau-utest/core : 41.2s (median 27.9s, delta +47.7%)
+ โ juneau-utest/core wall-clock 41.2s is +47.7% above rolling median
27.9s (last 20 runs, threshold 5%)
+ ```
+ - Warning-only โ never returns non-zero exit code. Doesn't block the push.
+3. **Wire into `scripts/push.py`.** After the `python3 scripts/test.py --full`
invocation succeeds:
+ - Pass `--timing-log <path>` per OQ #2
(`~/.cache/juneau-push-timings/<branch>.jsonl` if going with out-of-repo).
+ - Run `python3 scripts/push-timings.py --log <path>` to surface deltas.
Capture stdout, prefix with `๐ Push-timing report:` for the user.
+ - Continue to the build + commit + push steps regardless of timing-report
output.
+4. **Bootstrap the log.** First-run-with-empty-log behavior: skip delta check,
just write the baseline record. Subsequent runs have data to compare against.
+
+**Acceptance:** running `./scripts/push.py "test message" --dry-run` (after
extending dry-run to include timing read/write) on a clean working tree prints
"first run, no baseline" โ second `/push` on the same branch prints the
per-execution timing block with delta percentages โ fabricated 10% wall-clock
blow-up in a unit test triggers the warning banner on the affected execution
(above the 5% threshold) while the unaffected execution prints only its
baseline numbers โ `JUNEAU_PUSH_TIMING [...]
+
+### Phase 3 โ Container-type rollup (deliverable d)
+
+1. **`scripts/surefire-rollup.py`** โ walks both bucketed Surefire dirs:
+ - `<module>/target/surefire-reports/core/TEST-*.xml`
+ - `<module>/target/surefire-reports/container/TEST-*.xml`
+2. **Sub-bucket the container dir** by reading each test class's
`@SpringbootTest` / `@JettyMicroserviceTest` annotation (or by class-suffix as
a fallback for the drift cases). Two sub-buckets: `container.springboot`,
`container.jetty`.
+3. **Aggregate** per-module per-bucket wall-clock totals. Output:
+ ```
+ juneau-utest/
+ core : โโโโโโโโโโโโโโโโโ (1237 tests, 28.412s)
+ container.springboot: โโโโโโโโโโโ (10 tests, 11.812s)
+ container.jetty : โโโโโโโโโโ (12 tests, 6.781s)
+ ```
+4. **Integrate with Phase 2.** Same `--timing-log` writer optionally writes
per-sub-bucket records; reporter optionally summarizes with `--by-container`.
+
+**Acceptance:** running the rollup over a fresh
`target/surefire-reports/{core,container}/` produces a per-bucket table
matching the inventory baseline (1237 core, 10 springboot, 12 jetty) โ numbers
match a hand-count of `<testsuite time="โฆ">` values across each bucket dir.
+
+### Phase 4 โ Profiling pass + TODO-999 handoff (deliverable c)
+
+1. **`scripts/test.py --profile <module>` flag.** Adds
`-DargLine='-XX:StartFlightRecording=โฆ'` (JFR โ see OQ #4) or `-agentpath:`
(async-profiler) to the `mvn test` invocation. Writes the `.jfr` (or `.html`
flame graph) output to `target/profile-results/<module>-<ts>.jfr` per OQ #5.
+2. **One-shot profiling run** over the heaviest tagged buckets:
+ - `juneau-utest` core (1237 tests, the heavy work).
+ - `juneau-utest` container.springboot (slowest per-test).
+3. **Open each `.jfr` in JDK Mission Control** (or render flame graphs if
using async-profiler). Look for:
+ - Hot allocation sites in `BeanContext.getClassMeta(...)` (recurring
suspect per FINISHED-103's findings).
+ - Regex re-compilation in `UrlPathMatcher` and similar.
+ - Redundant `VarResolver.resolve(...)` calls (FINISHED-103 already
addressed many via `VarTemplateCompiler` โ expect fewer findings here than
initially anticipated).
+ - Serializer-session warm-up cost
(first-`MockRestClient.create()`-per-class spike).
+ - Spring `Environment.getProperty(...)` walks on unknown keys
(FINISHED-79's `SpringEnvironmentPropertySource` should memoize, but verify).
+4. **File findings into
[`TODO-999-final-prerelease-cleanup.md`](TODO-999-final-prerelease-cleanup.md)'s
"Working notes" section.** Format per existing TODO-999 convention: bullet per
finding with file/line pointer + one-sentence summary + flame-graph screenshot
or `.jfr` path. **Do NOT inline the fixes here.**
+
+**Acceptance:** `./scripts/test.py --profile juneau-utest` produces a `.jfr`
file under `target/profile-results/` โ at least one actionable finding is filed
to TODO-999's working-notes section with a precise pointer
(`juneau-marshall/.../BeanContext.java:LXXX`) โ the finding rationale
references the flame-graph evidence.
+
+## Acceptance criteria
+
+- **Phase 1**: 22 container-bound classes carry `@SpringbootTest` /
`@JettyMicroserviceTest`; `scripts/check-container-tags.py` passes on the
current tree and fails on a fabricated untagged container-booting test;
dual-execution Surefire produces bucketed `reportsDirectory` outputs;
`scripts/coverage.py` aggregate coverage matches pre-tag.
+- **Phase 2**: `scripts/push.py` invocation appends per-execution JSONL
records per push; `scripts/push-timings.py` always prints the per-execution
numbers + delta percentages, and surfaces a โ warning banner when a fabricated
>5% delta is introduced.
+- **Phase 3**: `scripts/surefire-rollup.py` produces a per-bucket table
consistent with the inventory baseline.
+- **Phase 4**: at least one `.jfr` profile captured; at least one actionable
optimization finding filed into TODO-999's "Working notes".
+
+## Risks
+
+- **Tag-discipline drift** โ a new test boots a container without
`@SpringbootTest` / `@JettyMicroserviceTest`, sneaking into the core bucket and
re-introducing the original "slow tests hide perf regressions" problem.
Mitigation: `scripts/check-container-tags.py` as a `/push` precondition catches
this on the next push attempt.
+- **`scripts/check-container-tags.py` heuristic gaps.** The detection
heuristics in OQ #7 might miss a new container-boot pattern (e.g. a test that
uses raw `SpringApplication.run(...)` instead of `@SpringBootTest`).
Mitigation: review the heuristics quarterly; treat any FINISHED-79-class
incident as a signal to extend the heuristic set.
+- **JUnit 5 tag-discovery quirks with meta-annotations.** JUnit-platform
discovery walks meta-annotation hierarchies (verified upstream behavior) โ but
a wrong-Surefire-plugin-version could behave differently. Mitigation: pin
Surefire >= 3.0.0 in the parent POM; verify the dual-execution config with a
smoke test before merging Phase 1.
+- **Surefire dual-execution interaction with `<testFailureIgnore>`.** Default
behavior: if the first execution fails, the second doesn't run, which would
hide container-test failures behind core-test failures. Mitigation: set
`<testFailureIgnore>true</testFailureIgnore>` on the first execution and check
the aggregate result in `scripts/test.py` post-hoc; OR sequence both executions
and report combined pass/fail explicitly.
+- **JaCoCo append-on-second-execution.** JaCoCo's `prepare-agent` goal must
use `append=true` for the second Surefire execution to add to (not overwrite)
the first execution's `.exec` file. Default behavior; verify.
+- **JFR profiling startup overhead.** A short test run with JFR enabled has
the JFR-init cost visible in the flame graph, which can mask real hotspots.
Mitigation: profile only the heaviest buckets; use
`-XX:StartFlightRecording=settings=profile` (medium-detail preset); ignore the
first few seconds.
+- **Out-of-repo timing log (OQ #2) means CI runs lose history.** A push from a
fresh `~/.cache/` produces no warnings ever. Acceptable if `/push` is
developer-local-only; needs revisiting if `/push` ever runs in CI.
+
+## Related work
+
+- **FINISHED-79**
([`FINISHED-79-value-annotation-config-bridge.md`](FINISHED-79-value-annotation-config-bridge.md))
โ the perf-incident lesson learned. The `RestContext.build()` โ
`Settings.get().addSource(...)` bridge dropped during cleanup is what this TODO
is designed to catch automatically next time.
+- **FINISHED-103**
([`FINISHED-103-varresolver-template-compilation.md`](FINISHED-103-varresolver-template-compilation.md))
โ already addressed many `VarResolver.resolve(...)` hotspots via the
`VarTemplateCompiler` retrofit. Expect Phase 4 profiling to find *less* on the
SVL side than initially anticipated; findings will skew toward `BeanContext` /
serializer-session cold-start.
+- **TODO-94a/b/c** (auth-filter framework + SAML + OAuth) โ sequenced after
TODO-105 so the auth-test PRs land with regression-tracking in place. The
tag-based scope makes this sequencing cheap.
+- **TODO-999**
([`TODO-999-final-prerelease-cleanup.md`](TODO-999-final-prerelease-cleanup.md))
โ recipient of the Phase 4 profiling findings. Do NOT migrate any of those
fixes into TODO-105. The drift-rename pass from OQ #8 is also a TODO-999
candidate if not done here.
+- **TODO-95** (per-RestContext `@Value("${cfg-key}")` resolution follow-on to
FINISHED-79). Independent of TODO-105 but motivated by the same incident.
diff --git a/todo/TODO.md b/todo/TODO.md
index 65f4785445..b5dd8033c5 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -33,8 +33,7 @@ Live work plan for the Juneau 9.5.0 development cycle.
Completed work is tracked
8. **TODO-89** โ `RateLimitGuard.Storage.snapshot()` SPI +
`BasicAdminResource` enrichment. Small (~30 LOC + 2 test files), closes a known
carry-over from FINISHED-77 (`"buckets": []` placeholder). Plan:
`todo/TODO-89-ratelimit-storage-snapshot-spi.md`.
9. **TODO-71** โ Doc-site script + Docusaurus search swap. Mostly already done
(planning worker found `juneau-docs/scripts/build-docs.py` +
`.github/workflows/deploy-docs.yml.disabled` already in place); this TODO is
now a cutover/cleanup + the Algolia โ `@easyops-cn/docusaurus-search-local`
swap. Plan: `todo/TODO-71-docs-site-script-search-swap.md`.
10. **TODO-88** โ YAML parser buffer-underflow on large OpenAPI 3.1 documents.
Latent parser bug in `juneau-marshall`; opportunistic. Plan file TBD.
-11. **TODO-105** โ Test-suite split + perf-regression tracking + profiling
pass. Lesson-learned from the FINISHED-79 perf incident (560s utest blow-up
caught only by manual observation): build the tooling to catch this
automatically next time. Three deliverables: (a) split `juneau-utest` into
`juneau-utest` (core: marshall / commons / dto / svl / config) +
`juneau-utest-rest` (everything REST-server-side, plus Microservice + Spring
Boot integration tests) so a core-only perf regression d [...]
-12. **TODO-106** โ FINISHED-archive follow-up/deferral sweep. Comb through
every `todo/FINISHED-<id>-*.md` archive (~55 older + ~15 recent post-TODO-81 =
~70 files) looking at the "Known follow-ups / explicit deferrals" /
"Carry-over" / "Open question" sections in each, plus inline `TODO: defer to
TODO-<n>` style pointers in the archive narrative. Surface every item that was
filed during execution but never promoted to a formal `[TODO-n]` bullet in
`todo/TODO.md`. Output: one consolidate [...]
+11. **TODO-106** โ FINISHED-archive follow-up/deferral sweep. Comb through
every `todo/FINISHED-<id>-*.md` archive (~55 older + ~15 recent post-TODO-81 =
~70 files) looking at the "Known follow-ups / explicit deferrals" /
"Carry-over" / "Open question" sections in each, plus inline `TODO: defer to
TODO-<n>` style pointers in the archive narrative. Surface every item that was
filed during execution but never promoted to a formal `[TODO-n]` bullet in
`todo/TODO.md`. Output: one consolidate [...]
**Phase F โ server-feature track (independent; can interleave anywhere):**
@@ -110,9 +109,3 @@ Live work plan for the Juneau 9.5.0 development cycle.
Completed work is tracked
- [TODO-106] FINISHED-archive follow-up/deferral sweep โ comb through every
`todo/FINISHED-<id>-*.md` archive (~70 files post-TODO-103 landing) and surface
"Known follow-ups / explicit deferrals / Carry-over / Open question" items that
were filed during execution but never promoted to formal `[TODO-n]` bullets in
`todo/TODO.md`. Many follow-ons are already explicitly tracked (TODO-92 โ
FINISHED-79 Phase 6, TODO-95 โ FINISHED-79 perf cleanup, TODO-89 โ FINISHED-77
admin enrichment, TODO-9 [...]
-- [TODO-105] Test-suite split + perf-regression tracking + profiling pass.
Driven by the lesson learned from the FINISHED-79 perf incident: a
`RestContext.build()` โ `Settings.get().addSource(...)` bridge leaked
`ConfigPropertySource` instances into a process-wide singleton because
`MockRestClient` caches `RestContext` statically and never invokes `destroy()`,
blowing the utest suite from ~35s to ~560s. Caught only by manual
`./scripts/test.py` observation after the user noticed the slow [...]
- - **(a) Test-suite split.** Break `juneau-utest` into two Maven modules:
`juneau-utest` (core only โ `juneau-marshall`, `juneau-commons`, `juneau-dto`,
`juneau-svl`, `juneau-config`, `juneau-cp` โ i.e. everything not under
`juneau-rest/` or `juneau-microservice/`) and a new `juneau-utest-rest`
(everything REST-server-side, plus the `JettyMicroservice` + `@SpringBootTest`
integration tests). Goal: a core-only perf regression like FINISHED-79's no
longer hides behind REST-side wall-clock [...]
- - **(b) Per-module timing capture in `/push`.** Wire `scripts/push.py`
(currently runs `mvn test` once and logs only pass/fail) to record per-module
wall-clock to a checked-in `target/.push-timings.jsonl` log (append-only, one
JSON line per `/push` run: `{ts, branch, commit, module, wallclock_s,
test_count, passed}`). Add a `scripts/push-timings.py` reporter that reads the
last N runs, computes a rolling median per module, and flags deltas >20% as
warnings on the next `/push` (warning- [...]
- - **(c) One-shot profiling pass.** Run async-profiler or JFR
(`scripts/test.py --profile <module>`) over the two largest test suites
post-split (likely `juneau-utest-rest` and the `juneau-marshall` subset of
`juneau-utest`) to surface concrete optimization candidates โ hot allocation
sites in `BeanContext.getClassMeta(...)`, regex re-compilation in
`UrlPathMatcher`, redundant `VarResolver.resolve(...)` calls (much of which
TODO-103 already targets), serializer-session warm-up cost, etc [...]
-
- Independent of all in-flight TODOs; no hard deps. **Suggested before
TODO-94a/b/c land** so the auth-filter PRs (which add a fair amount of test
surface) land with the new regression-tracking infrastructure already in place
โ that way the auth tests show up as new entries in the per-module timing log
from day one, not retrofitted later. Plan file TBD.