Merge pull request #1553 from apache/add-code-coverage

Add JaCoCo coverage measurement and Codecov reporting
diff --git a/.github/scripts/check-coverage-report.py b/.github/scripts/check-coverage-report.py
new file mode 100755
index 0000000..6c3e343
--- /dev/null
+++ b/.github/scripts/check-coverage-report.py
@@ -0,0 +1,97 @@
+#!/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.
+"""Sanity-check the aggregated JaCoCo report before it is published.
+
+Most Wicket tests live in a module other than the code they exercise, so the
+report depends on the dependency scopes declared in wicket-coverage/pom.xml:
+compile contributes classes, test contributes execution data only. If someone
+overrides maven-surefire-plugin's <argLine> without keeping the
+@{jacoco.argLine} placeholder, or changes those scopes, coverage silently drops
+to zero instead of failing the build. These assertions are that tripwire.
+
+This deliberately checks structure, not a coverage percentage. It is not a
+quality gate: it only fails when the measurement itself is broken.
+"""
+import sys
+import xml.etree.ElementTree as ET
+
+REPORT = 'wicket-coverage/target/site/jacoco-aggregate/jacoco.xml'
+
+# Every module listed at compile scope in wicket-coverage/pom.xml.
+EXPECTED_MODULES = {
+    'wicket-auth-roles', 'wicket-bean-validation', 'wicket-cdi', 'wicket-core',
+    'wicket-devutils', 'wicket-extensions', 'wicket-extensions-tester',
+    'wicket-guice', 'wicket-ioc', 'wicket-jmx', 'wicket-native-websocket-core',
+    'wicket-native-websocket-javax', 'wicket-native-websocket-tester',
+    'wicket-request', 'wicket-spring', 'wicket-tester', 'wicket-util',
+    'wicket-velocity',
+}
+
+# Modules whose tests live elsewhere. Zero here means cross-module attribution
+# has broken, which is the failure this script exists to catch.
+MUST_BE_COVERED = ('wicket-core', 'wicket-tester', 'wicket-cdi')
+
+
+def instructions(group):
+    for counter in group.findall('counter'):
+        if counter.get('type') == 'INSTRUCTION':
+            return int(counter.get('missed')), int(counter.get('covered'))
+    return 0, 0
+
+
+def main():
+    try:
+        root = ET.parse(REPORT).getroot()
+    except (OSError, ET.ParseError) as e:
+        sys.exit('cannot read %s: %s' % (REPORT, e))
+
+    groups = {g.get('name'): g for g in root.findall('group')}
+    failures = []
+
+    for name in sorted(groups):
+        missed, covered = instructions(groups[name])
+        total = missed + covered
+        pct = (100.0 * covered / total) if total else 0.0
+        print('%-34s %8d / %8d instructions  (%5.1f%%)' % (name, covered, total, pct))
+    print()
+
+    missing = EXPECTED_MODULES - set(groups)
+    extra = set(groups) - EXPECTED_MODULES
+    if missing:
+        failures.append('missing from the report: %s' % ', '.join(sorted(missing)))
+    if extra:
+        failures.append('unexpectedly present: %s -- update EXPECTED_MODULES here and '
+                        'the dependency list in wicket-coverage/pom.xml together'
+                        % ', '.join(sorted(extra)))
+
+    for name in MUST_BE_COVERED:
+        if name in groups and instructions(groups[name])[1] == 0:
+            failures.append('%s has zero coverage: its tests live in another module, so '
+                            'this means the JaCoCo agent did not attach or a dependency '
+                            'scope in wicket-coverage/pom.xml is wrong' % name)
+
+    if failures:
+        for f in failures:
+            print('FAIL: %s' % f, file=sys.stderr)
+        return 1
+    print('OK: %d modules reported, cross-module attribution intact' % len(groups))
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index 27c8891..015890e 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -53,4 +53,28 @@
     - name: Build with Maven
       run: |
         java -version
-        ./apache-maven-${MAVEN_VERSION}/bin/mvn --show-version clean verify -Pjs-test
+        ./apache-maven-${MAVEN_VERSION}/bin/mvn --show-version \
+          clean verify -Pjs-test \
+          ${{ matrix.java == '21' && '-Pcoverage' || '' }}
+
+    # Fails if the measurement itself has broken - for instance if a future
+    # <argLine> override drops the @{jacoco.argLine} placeholder, which would
+    # silently zero a module's coverage rather than failing the build.
+    - name: Check coverage report
+      if: matrix.java == '21'
+      run: python3 .github/scripts/check-coverage-report.py
+
+    # Coverage is measured on one JDK only: a single number is all that is needed,
+    # and this leaves the other legs' timings untouched. The aggregated report is
+    # produced by the wicket-coverage module; see the 'coverage' profile in pom.xml.
+    - name: Upload coverage to Codecov
+      # v7.0.0 - external actions must be pinned to a commit SHA:
+      # https://infra.apache.org/github-actions-policy.html
+      uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f
+      if: matrix.java == '21' && github.repository_owner == 'apache'
+      with:
+        files: ./wicket-coverage/target/site/jacoco-aggregate/jacoco.xml
+        # Empty for pull requests from forks, which upload tokenlessly. Do NOT switch
+        # to pull_request_target to get at the secret; ASF policy forbids it.
+        token: ${{ secrets.CODECOV_TOKEN }}
+        disable_search: true
diff --git a/README.md b/README.md
index b6b89b1..fb2234a 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,29 @@
     creates wicket-(subproject)-x.y.z.jar(s) in according target subdirectories and 
     installs the jar files into your local Maven repository for use in other projects.
 
+Code coverage
+-------------
+
+Coverage is measured with JaCoCo and aggregated into a single report, because most
+Wicket tests live in a module other than the code they exercise (the tests for
+wicket-core are in wicket-core-tests, for example). To produce it:
+
+ - mvn clean verify -Pcoverage
+
+    writes the aggregated report to
+    wicket-coverage/target/site/jacoco-aggregate/index.html
+
+To build only the modules that feed the report, which is considerably faster:
+
+ - mvn clean verify -Pcoverage -pl wicket-coverage -am
+
+Note that a build without "clean" merges the previous run's data into the new report,
+because the JaCoCo agent appends by default.
+
+Every push and pull request also uploads this report to
+https://app.codecov.io/gh/apache/wicket. Coverage is reported there, never enforced:
+no coverage check can fail a build or block a merge.
+
 Migrating from 9.x
 ------------------
 
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..a5b8b91
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,33 @@
+# 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.
+
+# Coverage is reported, never enforced. 'informational' keeps the project and patch
+# checks visible on a pull request, showing the real numbers, while making them
+# incapable of failing a build or blocking a merge.
+coverage:
+  status:
+    project:
+      default:
+        informational: true
+    patch:
+      default:
+        informational: true
+
+comment:
+  layout: "condensed_header, diff, flags, components"
+  # Stay quiet on pull requests that do not move coverage, which is most of them.
+  require_changes: true
diff --git a/pom.xml b/pom.xml
index 9c5e0f5..d162101 100644
--- a/pom.xml
+++ b/pom.xml
@@ -124,6 +124,8 @@
         <module>wicket-migration</module>
 		<module>wicket-tester</module>
 		<module>wicket-extensions-tester</module>
+		<!-- must stay last: aggregates the JaCoCo data of every module above -->
+		<module>wicket-coverage</module>
     </modules>
 	<properties>
 		<!-- Encoding -->
@@ -132,6 +134,17 @@
 
 		<project.build.outputTimestamp>2026-01-31T19:00:35Z</project.build.outputTimestamp>
 
+		<!--
+			Late-bound placeholder for the JaCoCo agent's -javaagent argument, consumed
+			by maven-surefire-plugin's <argLine> as @{jacoco.argLine}.
+
+			This property MUST stay declared, even though it is empty: surefire only
+			substitutes @{x} for properties that actually exist, otherwise it passes the
+			literal "@{x}" to the forked JVM and every test module fails with
+			"Unrecognized option". jacoco:prepare-agent overwrites the value; see
+			<propertyName> in the 'coverage' profile.
+		-->
+		<jacoco.argLine />
 		<javadoc.additionalJOption />
 		<javadoc.jdk.apidocs.link>https://docs.oracle.com/en/java/javase/${java.specification.version}/docs/api/</javadoc.jdk.apidocs.link>
 
@@ -1123,7 +1136,14 @@
 						<includes>
 							<include>**/*Test.java</include>
 						</includes>
-						<argLine>--add-opens java.base/jdk.internal.loader=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED
+						<!--
+							@{jacoco.argLine} is replaced by surefire at fork time, and is empty
+							unless -Pcoverage is active. Any future override of <argLine> (in a
+							profile or a module) MUST keep this placeholder, or coverage for that
+							module silently drops to zero.
+						-->
+						<argLine>@{jacoco.argLine}
+							--add-opens java.base/jdk.internal.loader=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED
 							--add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED
 							--add-modules=ALL-SYSTEM</argLine>
 						<useModulePath>false</useModulePath>
@@ -1254,6 +1274,11 @@
 					</configuration>
 				</plugin>
 				<plugin>
+					<groupId>org.jacoco</groupId>
+					<artifactId>jacoco-maven-plugin</artifactId>
+					<version>${jacoco.version}</version>
+				</plugin>
+				<plugin>
 					<groupId>org.primefaces.extensions</groupId>
 					<artifactId>resources-optimizer-maven-plugin</artifactId>
 					<version>${resources-optimizer-maven-plugin.version}</version>
@@ -1357,30 +1382,37 @@
 		</profile>
 
 		<profile>
+			<!--
+				Attaches the JaCoCo agent to every surefire fork by setting the
+				'jacoco.argLine' property, which surefire's <argLine> consumes as
+				@{jacoco.argLine}.
+
+				The aggregated report is produced by the wicket-coverage module, which
+				declares a profile with this same id. Deliberately NO per-module 'report'
+				goal: most tests live in a module other than the code they exercise
+				(wicket-core has no tests of its own, wicket-core-tests has ~500), so
+				per-module reports would show 0% for wicket-core.
+
+				Usage: mvn clean verify -Pcoverage
+				Result: wicket-coverage/target/site/jacoco-aggregate/{index.html,jacoco.xml}
+			-->
 			<id>coverage</id>
 			<build>
 				<plugins>
 					<plugin>
 						<groupId>org.jacoco</groupId>
 						<artifactId>jacoco-maven-plugin</artifactId>
-						<version>${jacoco.version}</version>
-
 						<executions>
 							<execution>
 								<id>jacoco-initialize</id>
 								<goals>
 									<goal>prepare-agent</goal>
 								</goals>
-							</execution>
-							<execution>
-								<id>jacoco-site</id>
-								<phase>package</phase>
-								<goals>
-									<goal>report</goal>
-								</goals>
+								<configuration>
+									<propertyName>jacoco.argLine</propertyName>
+								</configuration>
 							</execution>
 						</executions>
-
 					</plugin>
 				</plugins>
 			</build>
diff --git a/wicket-coverage/pom.xml b/wicket-coverage/pom.xml
new file mode 100644
index 0000000..bac3a56
--- /dev/null
+++ b/wicket-coverage/pom.xml
@@ -0,0 +1,209 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+   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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<parent>
+		<groupId>org.apache.wicket</groupId>
+		<artifactId>wicket-parent</artifactId>
+		<version>11.0.0-SNAPSHOT</version>
+		<relativePath>../pom.xml</relativePath>
+	</parent>
+	<artifactId>wicket-coverage</artifactId>
+	<packaging>pom</packaging>
+	<name>Wicket Code Coverage</name>
+	<description>
+		Build-only module that aggregates the JaCoCo execution data of all Wicket
+		modules into a single report. Not released, and does nothing unless the
+		'coverage' profile is active.
+
+		The dependency list below IS the configuration. jacoco:report-aggregate reads
+		the scope of each dependency:
+		  compile -> the module's classes and sources appear in the report, and its
+		             own target/jacoco.exec is read;
+		  test    -> only the module's target/jacoco.exec is read. Used for the
+		             test-only modules that hold the tests of a *different* module.
+	</description>
+	<properties>
+		<japicmp.skip>true</japicmp.skip>
+		<maven.deploy.skip>true</maven.deploy.skip> <!-- this module is not released -->
+	</properties>
+	<dependencies>
+		<!--
+			compile scope: classes + sources + own execution data are reported.
+			Scopes are stated explicitly on purpose, because the whole design hinges
+			on Dependency.getScope().
+		-->
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-auth-roles</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-bean-validation</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-cdi</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-core</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-devutils</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-extensions</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-extensions-tester</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-guice</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-ioc</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-jmx</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-native-websocket-core</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-native-websocket-javax</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-native-websocket-tester</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-request</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-spring</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<!-- managed to 'test' in wicket-parent; forced to compile so that its classes are reported -->
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-tester</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-util</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-velocity</artifactId>
+			<scope>compile</scope>
+		</dependency>
+		<!--
+			test scope: execution data only, no classes or sources. These modules have
+			no main sources at all; they hold the tests for the modules above.
+			  wicket-core-tests -> the tests of wicket-core AND wicket-tester
+			  wicket-cdi-tests  -> the only tests wicket-cdi has
+
+			Deliberately omitted, and why:
+			  wicket, wicket-experimental, wicket-native-websocket
+			                         aggregator POMs, no classes
+			  wicket-examples        demo webapp, not a released library; including it
+			                         would swamp the framework signal and drag jetty,
+			                         weld and httpunit into this POM's compile graph.
+			                         To let its tests count towards wicket-core, add it
+			                         here as <scope>test</scope><type>war</type> and
+			                         re-check dependency convergence.
+			  wicket-user-guide      no Java sources (asciidoctor documentation)
+			  archetypes/quickstart  no Java sources (packaging=maven-archetype)
+			  wicket-migration       no main Java sources (OpenRewrite recipes)
+			  wicket-metrics         no tests, so it would only contribute a 0% bundle
+			  wicket-objectsizeof-agent  a -javaagent JAR, not a classpath artifact
+			  wicket-common-tests    its single test only opens jar files off the
+			                         classpath; no meaningful coverage
+			  wicket-js-tests        surefire is skipped there, so no jacoco.exec exists
+		-->
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-cdi-tests</artifactId>
+			<version>${project.version}</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.wicket</groupId>
+			<artifactId>wicket-core-tests</artifactId>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+	<profiles>
+		<profile>
+			<!--
+				Same id as the profile in wicket-parent that attaches the agent, so a
+				single -Pcoverage switches on both halves of the feature.
+
+				Gated on the profile rather than unconditional on purpose: with no
+				execution data present, report-aggregate still emits a well-formed 0%
+				jacoco.xml, which would be a trap for anything consuming that path.
+			-->
+			<id>coverage</id>
+			<build>
+				<plugins>
+					<plugin>
+						<groupId>org.jacoco</groupId>
+						<artifactId>jacoco-maven-plugin</artifactId>
+						<executions>
+							<execution>
+								<!-- report-aggregate has no default phase; binding is mandatory -->
+								<id>jacoco-aggregate-report</id>
+								<phase>verify</phase>
+								<goals>
+									<goal>report-aggregate</goal>
+								</goals>
+							</execution>
+						</executions>
+					</plugin>
+				</plugins>
+			</build>
+		</profile>
+	</profiles>
+</project>