This is an automated email from the ASF dual-hosted git repository.

benw pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git

commit 1f3dd17f2a9c9f28bf165a555d30305e84a07591
Author: Ben Weidig <[email protected]>
AuthorDate: Wed Apr 15 09:28:07 2026 +0200

    TAP5-2809: run TestNG unit tests via Jupiter, integration tests native, 
more cleanup
    
    As using both JUnit and TestNG in the same project can be troublesome,
    we now created a deliberate split at integration testing level.
    
    Unit tests are run via the Jupiter testng-runner, whereas integration
    tests are still run with "native" TestNG, as SeleniumTestCase uses
    TestNG-only feature for its lifecycles.
    
    Furthermore, the Gradle files have been cleaned up and added some
    documentation to improve maintainability.
---
 Jenkinsfile                                        |  25 ++--
 Jenkinsfile.integration-variants                   |   2 +-
 build.gradle                                       |  63 +++++-----
 buildSrc/README.md                                 | 139 +++++++++++++++++++++
 buildSrc/src/main/groovy/t5build/SSshExec.groovy   |  21 ----
 buildSrc/src/main/groovy/t5build/Scp.groovy        |  36 ------
 buildSrc/src/main/groovy/t5build/SshTask.groovy    |  94 --------------
 .../main/groovy/t5build/TapestryBuildLogic.groovy  |  22 ----
 .../main/groovy/tapestry.java-convention.gradle    |  14 ++-
 .../tapestry.junit4-legacy-convention.gradle       |   8 ++
 .../main/groovy/tapestry.junit5-convention.gradle  |  14 ++-
 .../groovy/tapestry.junit5-spock-convention.gradle |   4 +
 .../src/main/groovy/tapestry.ssh-convention.gradle |  15 ---
 .../groovy/tapestry.testing-base-convention.gradle |  25 +++-
 .../main/groovy/tapestry.testng-convention.gradle  |  41 +++---
 gradle/libs.versions.toml                          |   8 +-
 quickstart/build.gradle                            |   8 +-
 settings.gradle                                    |   5 +-
 tapestry-beanvalidator/build.gradle                |  17 ++-
 tapestry-cdi/build.gradle                          |   1 -
 tapestry-core/build.gradle                         | 127 ++++++++-----------
 tapestry-core/src/test/resources/testng.xml        |  44 -------
 tapestry-hibernate-core/build.gradle               |   2 +-
 tapestry-hibernate/build.gradle                    |  17 ++-
 tapestry-hibernate/src/test/resources/testng.xml   |   5 -
 tapestry-http/build.gradle                         |  17 ++-
 tapestry-http/src/test/resources/testng.xml        |  11 ++
 tapestry-ioc/build.gradle                          |   2 +-
 tapestry-jmx/build.gradle                          |  15 +++
 tapestry-jpa/build.gradle                          |  15 +++
 .../jpa/JpaTransactionAdvisorImplTest.java         |  27 ++--
 .../org/apache/tapestry5/jpa/test/JpaTest.java     |  17 +--
 tapestry-kaptcha/build.gradle                      |  15 +++
 .../kaptcha/components/KaptchaUnitTest.java        |   4 +-
 tapestry-kaptcha/src/test/resources/testng.xml     |   1 -
 tapestry-spring/build.gradle                       |  17 ++-
 tapestry-spring/src/test/resources/testng.xml      |   6 -
 tapestry-test/build.gradle                         |  15 +++
 tapestry-upload/build.gradle                       |  15 +++
 tapestry-upload/src/test/resources/testng.xml      |   2 -
 tapestry-webresources/build.gradle                 |  18 ++-
 41 files changed, 515 insertions(+), 439 deletions(-)

diff --git a/Jenkinsfile b/Jenkinsfile
index 1965762bd..c3689bff7 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -2,7 +2,7 @@ pipeline {
     agent none
 
     environment {
-        GRADLE_OPTS = '-Dci=true -Dfile.encoding=UTF-8 
-Dselenium.wait.timeout=30'
+        GRADLE_OPTS = '-Dci=true -Dfile.encoding=UTF-8 
-Dselenium.wait.timeout=60'
     }
 
     options {
@@ -20,7 +20,11 @@ pipeline {
                     }
                 }
 
-                agent { node { label 'ubuntu' } }
+                agent {
+                    node {
+                        label 'ubuntu'
+                    }
+                }
 
                 tools {
                     jdk "${JDK_VERSION}"
@@ -47,7 +51,14 @@ pipeline {
                         }
                         post {
                             always {
-                                // Copy all reports and test results into a 
matrix-named folder
+                                // JUnit runs inside a matrix cell, so Jenkins 
automatically scopes the results
+                                junit(
+                                    testResults: 
"**/build/test-results/**/*.xml",
+                                    allowEmptyResults: true
+                                )
+
+                                // archiveArtifacts has no per-cell 
namespacing, so we copy them into a
+                                // JDK-named folder first to avoid cells 
overwriting each other
                                 sh """
                                     find . \\( -path '*/build/reports' -o 
-path '*/build/test-results' \\) \
                                         -not -path 
'./build/matrix-artifacts/*' -type d | while IFS= read -r src; do
@@ -56,14 +67,6 @@ pipeline {
                                         cp -r -- "\$src/." "\$dest/"
                                     done
                                 """
-
-                                // Point JUnit to the XMLs inside the new 
folder
-                                junit(
-                                    testResults: 
"build/matrix-artifacts/${JDK_VERSION}/**/test-results/**/*.xml",
-                                    allowEmptyResults: true
-                                )
-
-                                // Archive matrix artifacts
                                 archiveArtifacts(
                                     artifacts: 
"build/matrix-artifacts/${JDK_VERSION}/**/*",
                                     allowEmptyArchive: true
diff --git a/Jenkinsfile.integration-variants b/Jenkinsfile.integration-variants
index 6108572a5..011a1b477 100644
--- a/Jenkinsfile.integration-variants
+++ b/Jenkinsfile.integration-variants
@@ -5,7 +5,7 @@ pipeline {
 
     environment {
         // Disable the daemon to prevent parallal issues
-        GRADLE_OPTS = '-Dci=true -Dfile.encoding=UTF-8 
-Dorg.gradle.daemon=false'
+        GRADLE_OPTS = '-Dci=true -Dfile.encoding=UTF-8 
-Dselenium.wait.timeout=60 -Dorg.gradle.daemon=false'
     }
 
     tools {
diff --git a/build.gradle b/build.gradle
index b174ebceb..7c35bf751 100755
--- a/build.gradle
+++ b/build.gradle
@@ -1,5 +1,4 @@
 import t5build.GenerateChecksums
-import t5build.TapestryBuildLogic
 import org.apache.tools.ant.filters.ReplaceTokens
 
 plugins {
@@ -43,15 +42,16 @@ ext {
         'tapestry-core',
     ]
 
+    // Set via GRADLE_OPTS='-Dci=true' in CI (see Jenkinsfile).
+    // Controls version suffix, deploy target, and if integration tests will 
run headless
     continuousIntegrationBuild = Boolean.getBoolean('ci')
+
     stagingUrl = 
'https://repository.apache.org/service/local/staging/deploy/maven2/'
     snapshotUrl = 
'https://repository.apache.org/content/repositories/snapshots'
     doSign = !project.hasProperty('noSign') && 
project.hasProperty('signing.keyId')
 
     // apacheDeployUserName and apacheDeployPassword should be specified in 
~/.gradle/gradle.properties
-
-    def deployFlavor = TapestryBuildLogic.isSnapshot(project) ? 'snapshot' : 
'apache'
-
+    def deployFlavor = continuousIntegrationBuild ? 'snapshot' : 'apache'
     deployUsername = providers.gradleProperty("${deployFlavor}DeployUserName")
     deployPassword = providers.gradleProperty("${deployFlavor}DeployPassword")
     archiveDeployFolder = providers.gradleProperty('apacheArchivesFolder')
@@ -64,18 +64,18 @@ ext {
 }
 
 // Provided so that the CI server can override the normal version number for 
nightly builds.
-version = TapestryBuildLogic.tapestryVersion(project)
+version = continuousIntegrationBuild ? tapestryMajorVersion + '-SNAPSHOT' : 
tapestryMajorVersion + tapestryMinorVersion
 
 // Specific to top-level build, not set for subprojects:
 configurations {
     javadoc
-    published.extendsFrom archives, meta
 
+    published.extendsFrom archives, meta
     if (doSign) {
         published.extendsFrom signatures
     }
 
-    binaries // additional dependencies included in the binary archive
+    binaries
 }
 
 dependencies {
@@ -127,9 +127,8 @@ subprojects {
     }
 
     configurations {
-        // published -- what gets uploaded to the Nexus repository
+        // published: what gets uploaded to the Nexus repository
         published.extendsFrom archives, meta
-
         if (rootProject.doSign) {
             published.extendsFrom signatures
         }
@@ -147,8 +146,9 @@ subprojects {
 
                 pom {
                     name = project.name
-                    // TODO: find some way to get the subproject description 
here.
-                    // description =
+                    description = providers.provider {
+                        project.description
+                    }
                     url = 'https://tapestry.apache.org/'
                     licenses {
                         license {
@@ -159,13 +159,12 @@ subprojects {
                     scm {
                         connection = 
'scm:git:https://gitbox.apache.org/repos/asf/tapestry-5.git'
                         developerConnection = 
'scm:git://gitbox.apache.org/repos/asf/tapestry-5.git'
-                        url = 
'https://git-wip-us.apache.org/repos/asf?p=tapestry-5.git;a=summary'
+                        url = 
'https://git-wip-us.apache.org/repos/asf?p=tapestry-5.git'
                     }
                     // Changes the generated pom.xml so its dependencies on 
suffixed artifacts
                     // get properly updated with suffixed artifact ids
                     withXml {
-                        def artifactIdQName = new groovy.namespace.QName(
-                                'http://maven.apache.org/POM/4.0.0', 
'artifactId')
+                        def artifactIdQName = new 
groovy.namespace.QName('http://maven.apache.org/POM/4.0.0', 'artifactId')
                         def node = asNode()
                         def dependencies = node.get('dependencies')[0]
                         if (dependencies != null) {
@@ -180,6 +179,7 @@ subprojects {
                 }
             }
         }
+
         if (canDeploy) {
             repositories {
                 mavenLocal()
@@ -206,8 +206,9 @@ subprojects {
         }
     }
 
+    // Modules not in suffixedArtifactNames have no -jakarta counterpart
+    // and should not be published when artifactSuffix is empty (i.e. the 
javax branch)
     def actuallyPublish = !artifactSuffix.isEmpty() || 
suffixedArtifactNames.contains(project.name)
-    // println 'XXXXXX Actually publish? ' + actuallyPublish + ' project ' + 
project.name + ' ' + '!artifactSuffix.isEmpty() ' + !artifactSuffix.isEmpty()
     if (!actuallyPublish) {
         tasks.withType(PublishToMavenRepository).configureEach {
             it.enabled = false
@@ -230,7 +231,7 @@ subprojects {
 // Cribbed from 
https://github.com/hibernate/hibernate-core/blob/master/release/release.gradle#L19
 
 tasks.register('aggregateJavadoc', Javadoc) {
-    group = 'Documentation'
+    group = 'documentation'
     description = 'Build the aggregated JavaDocs for all modules'
 
     dependsOn configurations.javadoc
@@ -299,14 +300,14 @@ tasks.register('aggregateJavadoc', Javadoc) {
 }
 
 tasks.register('typeScriptDocs') {
-    group = 'Documentation'
+    group = 'documentation'
     description = 'Builds typedoc documentation for all TypeScript sources'
 
     dependsOn(':tapestry-core:generateTypeScriptDocs')
 }
 
 tasks.register('combinedJacocoReport', JacocoReport) {
-    group = 'Verification'
+    group = 'verification'
     description = 'Generates combined JaCoCo coverage report for all 
subprojects'
     
     def excludedProjects = [
@@ -325,7 +326,7 @@ tasks.register('combinedJacocoReport', JacocoReport) {
     def subprojectsToConsider = subprojects.findAll {
         !excludedProjects.contains(it.name)
     }
-    dependsOn subprojectsToConsider.test
+    dependsOn subprojectsToConsider.collect { it.tasks.named('check') }
 
     def classFiles = 
files(subprojectsToConsider.sourceSets.main.output).asFileTree.matching {
         exclude 'org/apache/tapestry5/internal/plastic/asm/**'
@@ -334,9 +335,12 @@ tasks.register('combinedJacocoReport', JacocoReport) {
 
     
additionalSourceDirs.from(files(subprojectsToConsider.sourceSets.main.allSource.srcDirs))
     
sourceDirectories.from(files(subprojectsToConsider.sourceSets.main.allSource.srcDirs))
-    
executionData.from(files(subprojectsToConsider.jacocoTestReport.executionData).filter
 {
-        it.exists()
+    // Glob picks up all exec files regardless of task name: test.exec, 
testNG.exec, etc.
+    executionData.from(subprojectsToConsider.collect { p ->
+        p.fileTree(p.layout.buildDirectory) { include 'jacoco/*.exec' }
     })
+
+    // Reuse the JaCoCo agent classpath that each subproject's 
jacocoTestReport task already resolved.
     jacocoClasspath = 
files(subprojectsToConsider.jacocoTestReport.jacocoClasspath)
 
     reports {
@@ -353,7 +357,7 @@ tasks.register('combinedJacocoReport', JacocoReport) {
 }
 
 tasks.register('continuousIntegration') {
-    group = 'Verification'
+    group = 'verification'
     description = 'Runs a full CI build: assembles all artifacts, runs all 
checks, and generates aggregate reports.'
 
     def dependants = [
@@ -361,20 +365,16 @@ tasks.register('continuousIntegration') {
         'tapestry-core:testWithJqueryAndRequireJsDisabled',
         'tapestry-core:testWithPrototypeAndRequireJsEnabled', 
         subprojects.check, // jQuery and Require.js enabled
-        combinedJacocoReport
+        combinedJacocoReport,
+        aggregateJavadoc
     ]
 
-    // tapestry-javadoc doesn't work with Java 8 anymore. That's why it's only 
added if != 8.
-    if (JavaVersion.current() != JavaVersion.VERSION_1_8) {
-        dependants << aggregateJavadoc
-    }
-
     dependsOn(dependants)
 }
 
 
 tasks.register('zippedSources', Zip) {
-    group = 'Release artifact'
+    group = 'release artifact'
     description = "Creates a combined Zip file of all sub-project's sources"
     
     dependsOn 'tapestry-core:compileTypeScript'
@@ -399,7 +399,7 @@ tasks.register('zippedSources', Zip) {
 }
 
 tasks.register('zippedApidoc', Zip) {
-    group = 'Release artifact'
+    group = 'release artifact'
     description = "Zip archive of the project's aggregate JavaDoc and 
TypeScript documentation"
 
     dependsOn typeScriptDocs
@@ -470,7 +470,6 @@ if (canDeploy) {
         uploads.extendsFrom archives, signatures
     }
 
-
     artifacts {
         archives zippedApidoc, zippedSources, zippedBinaries
     }
@@ -520,7 +519,7 @@ if (canDeploy) {
 }
 
 tasks.register('updateBootstrap') {
-    group = 'Maintenance'
+    group = 'maintenance'
     description = 'Updates the included Bootstrap dependencies from GitHub'
 
     doLast {
diff --git a/buildSrc/README.md b/buildSrc/README.md
new file mode 100644
index 000000000..d5c83b7f8
--- /dev/null
+++ b/buildSrc/README.md
@@ -0,0 +1,139 @@
+# buildSrc: Tapestry Build Conventions
+
+Gradle convention plugins and build utilities shared across all subprojects.
+All convention files live in `src/main/groovy/` as precompiled script plugins.
+
+---
+
+## Convention hierarchy
+
+```text
+tapestry.java-convention                    applied to every subproject by 
root build.gradle
+ └─ tapestry.testing-base-convention        JUnit Platform runner, shared test 
config
+    ├─ tapestry.junit5-convention             + Jupiter API + engine
+    │   ├─ tapestry.junit5-spock-convention       + Spock BOM / spock-core
+    │   └─ tapestry.junit4-legacy-convention      + Vintage engine (JUnit 4 
tests)
+    └─ tapestry.testng-convention             + TestNG + EasyMock + 
testng-engine
+```
+
+---
+
+## Conventions
+
+### `tapestry.java-convention`
+
+Applied automatically to every subproject by the root `build.gradle`.
+
+*   Java 11 source/target compatibility
+*   `provided` configuration (compile-only scope, like Maven 
`<scope>provided</scope>`)
+*   Dependency version constraints to keep library versions consistent across 
modules
+*   JAR manifest: `Automatic-Module-Name` (JPMS) and `LICENSE`/`NOTICE` in 
`META-INF`
+
+---
+
+### `tapestry.testing-base-convention`
+
+Foundation for all test conventions.
+
+**Do not apply directly**
+
+Use one of the specialized conventions below.
+
+Configures every `Test` task in the project via `tasks.withType(Test)`:
+
+*   JUnit Platform as the runner (required by all engines: Jupiter, TestNG, 
Vintage)
+*   `junit-platform-launcher` and the JUnit BOM on the runtime classpath
+*   Standard system properties: file encoding, locale, CI flag, Selenium wait 
timeout
+*   Consistent test logging: full exception format, pass/skip/fail events, 
progress counter
+
+### `tapestry.junit5-convention`
+
+Use when the module's test sources use JUnit 5 Jupiter (`@Test`, 
`@ExtendWith`, ...).
+
+Adds Jupiter API to the test compile classpath and the Jupiter engine at 
runtime.
+
+```groovy
+plugins {
+    id 'tapestry.junit5-convention'
+}
+```
+
+### `tapestry.junit5-spock-convention`
+
+Use when the module writes tests in Spock (Groovy BDD framework).
+
+Extends `junit5-convention` with the Spock BOM and `spock-core`.
+Spock runs on the JUnit Platform — no additional runner config needed.
+
+```groovy
+plugins {
+    id 'tapestry.junit5-spock-convention'
+}
+```
+
+### `tapestry.junit4-legacy-convention`
+
+Use **only** for modules that still have JUnit 4 test sources (`@RunWith`, 
`@Rule`, ...).
+
+Extends `junit5-convention` with the JUnit Vintage engine, so legacy tests run 
alongside any Jupiter tests without touching test source code.
+Prefer migrating tests over adding this convention to new modules.
+
+```groovy
+plugins {
+    id 'tapestry.junit4-legacy-convention'
+}
+```
+
+### `tapestry.testng-convention`
+
+Use when the module's test sources use TestNG annotations (`@Test`, 
`@BeforeMethod`, `@DataProvider`, ...).
+
+Adds TestNG and EasyMock to the test compile classpath.
+The `testng-engine` dependency at runtime lets the standard `test` task (JUnit 
Platform) discover and run TestNG unit tests automatically.
+
+```groovy
+plugins {
+    id 'tapestry.testng-convention'
+}
+```
+
+#### Modules with Selenium integration tests
+
+When a module has both TestNG unit tests and Selenium integration tests, 
_native_ TestNG **must** be used for the integration tests!
+
+This is because Selenium tests currently rely on `@BeforeTest` / 
`ITestContext` scoping that only works correctly with the native TestNG runner 
and a `testng.xml` suite file.
+Each `<test>` element gets its own server instance with the right webapp.
+
+The required additions to the module's `build.gradle`:
+
+```groovy
+// Exclude integration classes from the JUnit Platform 'test' task
+tasks.named('test') {
+    exclude '**/integration/**'   // adjust pattern to match your integration 
tests
+}
+
+// Native TestNG task: uses testng.xml for correct ITestContext grouping
+tasks.register('testNG', Test) {
+    group = 'verification'
+
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+// Include in the check lifecycle
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+```
+
+The `testng.xml` suite file for these modules should contain **only** 
integration `<test>` elements.
+
+Unit tests are handled by the `test` task via testng-engine by Jupiter and 
must not appear in `testng.xml`.
+
+---
+
+## Helper classes (`t5build` package)
+
+**`GenerateChecksums`**: custom Gradle task type that generates MD5/SHA-256 
checksum
+files for release archives.
diff --git a/buildSrc/src/main/groovy/t5build/SSshExec.groovy 
b/buildSrc/src/main/groovy/t5build/SSshExec.groovy
deleted file mode 100644
index e985b4591..000000000
--- a/buildSrc/src/main/groovy/t5build/SSshExec.groovy
+++ /dev/null
@@ -1,21 +0,0 @@
-package t5build
-
-import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.TaskAction
-
-class SshExec extends SshTask {
-
-    @Input
-    List<String[]> commandLines = []
-
-    void commandLine(String... commandLine) {
-        commandLines << commandLine
-    }
-
-    @TaskAction
-    void doActions() {
-        commandLines.each { commandLine ->
-            ssh(*commandLine)
-        }
-    }
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/t5build/Scp.groovy 
b/buildSrc/src/main/groovy/t5build/Scp.groovy
deleted file mode 100644
index 3280cfb9d..000000000
--- a/buildSrc/src/main/groovy/t5build/Scp.groovy
+++ /dev/null
@@ -1,36 +0,0 @@
-package t5build
-
-import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.InputFiles
-import org.gradle.api.tasks.SkipWhenEmpty
-import org.gradle.api.tasks.TaskAction
-import java.io.File
-
-class Scp extends SshTask {
-
-    @InputFiles @SkipWhenEmpty
-    def source
-
-    @Input
-    String destination
-
-    @Input
-    boolean isDir = false
-
-    @TaskAction
-    void doActions() {
-        if (isDir) {
-            scpDir(source, destination)
-            return
-        }
-        project.files(source).each { doFile(it) }
-    }
-
-    private void doFile(File file) {
-        if (file.isDirectory()) {
-            file.eachFile { doFile(it) }
-        } else {
-            scpFile(file, destination)
-        }
-    }
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/t5build/SshTask.groovy 
b/buildSrc/src/main/groovy/t5build/SshTask.groovy
deleted file mode 100644
index 769b32adf..000000000
--- a/buildSrc/src/main/groovy/t5build/SshTask.groovy
+++ /dev/null
@@ -1,94 +0,0 @@
-package t5build
-
-import org.gradle.api.DefaultTask
-import org.gradle.api.file.FileCollection
-import org.gradle.api.logging.LogLevel
-import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.InputFiles
-import org.gradle.api.tasks.Internal
-
-abstract class SshTask extends DefaultTask {
-
-    @InputFiles
-    FileCollection sshAntClasspath
-
-    @Input
-    String host
-
-    @Input
-    String userName
-
-    // TODO: Passwords should not be plain @Input.
-    @Input
-    String password
-
-    @Input
-    boolean verbose = false
-
-    private boolean antInited = false
-
-    protected void initAnt() {
-        if (antInited) {
-            return
-        }
-        ant.taskdef(name: 'scp',
-                    classname: 
'org.apache.tools.ant.taskdefs.optional.ssh.Scp',
-                    classpath: sshAntClasspath.asPath,
-                    loaderref: 'ssh')
-
-        ant.taskdef(name: 'sshexec',
-                    classname: 
'org.apache.tools.ant.taskdefs.optional.ssh.SSHExec',
-                    classpath: sshAntClasspath.asPath,
-                    loaderref: 'ssh')
-        antInited = true
-    }
-
-    protected void withInfoLogging(Closure action) {
-        def oldLogLevel = getLogging().getLevel()
-        getLogging().setLevel([LogLevel.INFO, oldLogLevel].min())
-        try {
-            action()
-        } finally {
-            if (oldLogLevel != null) {
-                getLogging().setLevel(oldLogLevel)
-            }
-        }
-    }
-
-    protected void scpFile(Object source, String destination) {
-        initAnt()
-        withInfoLogging {
-            // TODO: This keyfile is hardcoded and uses an old algorithm (dsa)
-            ant.scp(localFile: project.files(source).singleFile,
-                    remoteToFile: "${userName}@${host}:${destination}",
-                    keyfile: "${System.properties['user.home']}/.ssh/id_dsa",
-                    verbose: verbose)
-        }
-    }
-
-    protected void scpDir(Object source, String destination) {
-        initAnt()
-        withInfoLogging {
-            ant.sshexec(host: host,
-                        username: userName,
-                        password: password,
-                        command: "mkdir -p ${destination}")
-
-            ant.scp(remoteTodir: "${userName}@${host}:${destination}",
-                    keyfile: "${System.properties['user.home']}/.ssh/id_dsa",
-                    verbose: verbose) {
-                project.files(source).addToAntBuilder(ant, 'fileSet', 
FileCollection.AntType.FileSet)
-            }
-        }
-    }
-
-    protected void ssh(Object... commandLine) {
-        initAnt()
-        withInfoLogging {
-            ant.sshexec(host: host,
-                        username: userName,
-                        password: password,
-                        command: commandLine.join(' '))
-        }
-    }
-}
diff --git a/buildSrc/src/main/groovy/t5build/TapestryBuildLogic.groovy 
b/buildSrc/src/main/groovy/t5build/TapestryBuildLogic.groovy
deleted file mode 100644
index 13f9b935a..000000000
--- a/buildSrc/src/main/groovy/t5build/TapestryBuildLogic.groovy
+++ /dev/null
@@ -1,22 +0,0 @@
-package t5build
-
-import org.gradle.api.Project
-
-class TapestryBuildLogic {
-
-    static boolean isSnapshot(Project project) {
-        return tapestryVersion(project).endsWith('SNAPSHOT')
-    }
-
-    static boolean isWindows() {
-        return System.properties['os.name'].toLowerCase().contains('windows')
-    }
-
-    static String tapestryVersion(Project project) {
-        String major = project.rootProject.ext.tapestryMajorVersion
-        String minor = project.rootProject.ext.tapestryMinorVersion
-      
-        boolean isCiBuild = 
project.rootProject.hasProperty('continuousIntegrationBuild') && 
project.rootProject.continuousIntegrationBuild
-        return isCiBuild ? major + '-SNAPSHOT' : major + minor
-    }
-}
\ No newline at end of file
diff --git a/buildSrc/src/main/groovy/tapestry.java-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.java-convention.gradle
index 974633a7c..862bef814 100644
--- a/buildSrc/src/main/groovy/tapestry.java-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.java-convention.gradle
@@ -1,3 +1,9 @@
+// Base convention applied to every subproject in root build.gradle via 
`subprojects {}`
+//   - Java 11 source/target compatibility
+//   - 'provided' configuration (compile-only scope, like Maven 
<scope>provided</scope>)
+//   - Dependency version constraints to keep the classpath consistent across 
modules
+//   - JAR manifest: Automatic-Module-Name for JPMS, LICENSE/NOTICE in META-INF
+
 plugins {
     id 'java-library'
     id 'eclipse'
@@ -17,7 +23,10 @@ tasks.withType(JavaCompile).configureEach {
 }
 
 configurations {
+    // 'provided': on compile + test classpaths but excluded from the runtime 
jar/war.
     provided
+
+    // 'meta': collects artifacts (e.g. sourcesJar) that should be published 
alongside the main jar.
     meta
 }
 
@@ -45,7 +54,7 @@ dependencies {
         implementation libs.commons.codec
         implementation libs.commons.io
         implementation libs.commons.logging
-        
implementation("${libs.hsqldb.get().module.group}:${libs.hsqldb.get().module.name}:${libs.hsqldb.get().version}:jdk8")
+        implementation libs.hsqldb
         implementation libs.xml.apis
     }
 }
@@ -57,7 +66,8 @@ tasks.withType(Jar).configureEach {
         into 'META-INF'
     }
 
-    // JPMS compatibility
+    // Stable module name for JPMS (e.g. tapestry-ioc → 
org.apache.tapestry.ioc).
+    // Keeps the module name consistent even if the JAR file is renamed.
     manifest {
         attributes("Automatic-Module-Name": 
"org.apache.tapestry.${project.name}"
         .replace('tapestry-', '')
diff --git a/buildSrc/src/main/groovy/tapestry.junit4-legacy-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.junit4-legacy-convention.gradle
index ac37c9b3a..fc7e39272 100644
--- a/buildSrc/src/main/groovy/tapestry.junit4-legacy-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.junit4-legacy-convention.gradle
@@ -1,3 +1,11 @@
+// Use ONLY for existing modules still using JUnit 4 test sources (@RunWith, 
@Rule, etc.).
+//
+// Adds the JUnit Vintage engine so those tests run on the JUnit Platform 
alongside
+// any Jupiter tests in the same module.
+// No test source changes required.
+//
+// Prefer migrating tests to JUnit 5 over adding this convention to new 
modules.
+
 plugins {
     id 'tapestry.junit5-convention'
 }
diff --git a/buildSrc/src/main/groovy/tapestry.junit5-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.junit5-convention.gradle
index 59c163eb6..ee2ecdaea 100644
--- a/buildSrc/src/main/groovy/tapestry.junit5-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.junit5-convention.gradle
@@ -1,14 +1,16 @@
+// Use for modules whose test sources use JUnit 5 (Jupiter) annotations 
(@Test, @ExtendWith, …).
+//
+// Convention hierarchy:
+//   tapestry.testing-base-convention           platform runner, BOM, logging
+//    └─ tapestry.junit5-convention               + Jupiter API + engine (this 
file)
+//        ├─ tapestry.junit5-spock-convention         + Spock
+//        └─ tapestry.junit4-legacy-convention        + Vintage engine for 
JUnit 4
+
 plugins {
     id 'tapestry.testing-base-convention'
 }
 
 dependencies {
-    testImplementation platform(libs.junit.bom)
     testImplementation libs.bundles.junit.jupiter.essentials
     testRuntimeOnly libs.junit.jupiter.engine
-    testRuntimeOnly libs.junit.platform.launcher
-}
-
-tasks.withType(Test).configureEach {
-    useJUnitPlatform()
 }
diff --git a/buildSrc/src/main/groovy/tapestry.junit5-spock-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.junit5-spock-convention.gradle
index 1570598ef..3c35d1a43 100644
--- a/buildSrc/src/main/groovy/tapestry.junit5-spock-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.junit5-spock-convention.gradle
@@ -1,3 +1,7 @@
+// Use for modules that write tests in Spock (Groovy-based BDD framework).
+// Extends tapestry.junit5-convention with the Spock BOM and spock-core.
+// Spock runs on the JUnit Platform, so no additional runner config is needed.
+
 plugins {
     id 'tapestry.junit5-convention'
     id 'groovy'
diff --git a/buildSrc/src/main/groovy/tapestry.ssh-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.ssh-convention.gradle
deleted file mode 100644
index 2f6aa7c8a..000000000
--- a/buildSrc/src/main/groovy/tapestry.ssh-convention.gradle
+++ /dev/null
@@ -1,15 +0,0 @@
-import t5build.SshTask
-
-// This configuration will hold the ant-jsch.jar at runtime
-configurations {
-    sshAntTask
-}
-
-dependencies {
-    sshAntTask libs.ant.jsch
-}
-
-// Configure all SshTask instances to use the files from the configuration
-tasks.withType(SshTask).configureEach {
-    it.sshAntClasspath = configurations.sshAntTask
-}
diff --git a/buildSrc/src/main/groovy/tapestry.testing-base-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.testing-base-convention.gradle
index 7a105f7e2..58cff9d52 100644
--- a/buildSrc/src/main/groovy/tapestry.testing-base-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.testing-base-convention.gradle
@@ -1,12 +1,29 @@
+// Shared test infrastructure for all testing conventions.
+// Applied indirectly via tapestry.junit5-convention and 
tapestry.testng-convention.
+//
+// DO NOT USE DIRECTLY!
+//
+// Configures every Test task in the project (via tasks.withType(Test)):
+//   - JUnit Platform as the test runner (required by both Jupiter and 
testng-engine)
+//   - junit-platform-launcher + JUnit BOM on the runtime classpath
+//   - Standard system properties: encoding, locale, CI flag, Selenium timeout
+//   - Consistent test logging (full exception format, pass/skip/fail events, 
progress counter)
+//
+// The JUnit BOM ensures all JUnit Platform artifacts (launcher, engines) use 
the same version,
+// even in modules that only use TestNG and never write a single 
@Test(jupiter) method.
+
 plugins {
     id 'java-library'
 }
 
 dependencies {
+    testImplementation platform(libs.junit.bom)
     testRuntimeOnly libs.slf4j.simple
+    testRuntimeOnly libs.junit.platform.launcher
 }
 
 tasks.withType(Test).configureEach { testTask ->
+    useJUnitPlatform()
     maxHeapSize = '600M'
 
     systemProperties(
@@ -26,6 +43,8 @@ tasks.withType(Test).configureEach { testTask ->
         showStandardStreams = true
     }
 
+    // The code below is a helper to output test progress during testing
+
     def total = 0, passed = 0, failed = 0, skipped = 0
 
     afterTest { descriptor, result ->
@@ -50,11 +69,11 @@ tasks.withType(Test).configureEach { testTask ->
         // The root suite has a null parent. We only want to log the final 
summary.
         if (descriptor.parent == null) {
             total = passed + failed + skipped
-            // Using project.path plus task gives a clear identifier like 
":tapestry-core:test"
-            def taskIdentifier = "${project.path}:${name}"
 
-            // Don't log if no tests were run
+            // Only log if we actually have tested something
             if (total > 0) {
+                // Using project.path plus task gives a clear identifier like 
":tapestry-core:test"
+                def taskIdentifier = "${project.path}:${name}"
                 logger.lifecycle 
"------------------------------------------------------------------------"
                 logger.lifecycle "Test Results for ${taskIdentifier}"
                 logger.lifecycle "  Tests run: ${total}, Passed: ${passed}, 
Failed: ${failed}, Skipped: ${skipped}"
diff --git a/buildSrc/src/main/groovy/tapestry.testng-convention.gradle 
b/buildSrc/src/main/groovy/tapestry.testng-convention.gradle
index 9ca8e29ac..083069931 100644
--- a/buildSrc/src/main/groovy/tapestry.testng-convention.gradle
+++ b/buildSrc/src/main/groovy/tapestry.testng-convention.gradle
@@ -1,3 +1,19 @@
+// Use for modules whose test sources use TestNG annotations (@Test, 
@BeforeMethod, ...).
+//
+// Adds TestNG + EasyMock to the test compile classpath, and testng-engine at 
runtime
+// so the standard 'test' task (JUnit Platform) discovers and runs TestNG unit 
tests
+// without any configuration change.
+//
+// Modules that ALSO have Selenium integration tests must additionally declare:
+//   - A 'testNG' task using useTestNG { suiteXmlFiles << ... } for native 
TestNG execution
+//     (needed for correct @BeforeTest / ITestContext scoping across multiple 
test apps)
+//   - An exclusion on the 'test' task to skip integration classes
+//   - tasks.named('check') { dependsOn 'testNG' }
+//
+// Convention hierarchy:
+//   tapestry.testing-base-convention       platform runner, BOM, logging
+//     └─ tapestry.testng-convention          + TestNG + EasyMock + 
testng-engine (this file)
+
 plugins {
     id 'tapestry.testing-base-convention'
 }
@@ -5,28 +21,5 @@ plugins {
 dependencies {
     testImplementation libs.testng
     testImplementation libs.easymock
-}
-
-def testNGTask = tasks.register('testNG', Test) {
-    group = 'verification'
-    description = 'Runs TetstNG tests.'
-
-    def suiteFiles = [
-        'src/test/resources/testng.xml',
-        'src/test/conf/testng.xml'
-    ].collect { path -> layout.projectDirectory.file(path).asFile }
-
-    def existingSuite = suiteFiles.find { it.exists() }
-
-    if (existingSuite) {
-        useTestNG {
-            suites(existingSuite)
-        }
-    } else {
-        useTestNG()
-    }
-}
-
-tasks.named('check') {
-    dependsOn(testNGTask)
+    testRuntimeOnly libs.testng.engine
 }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1384d14f2..6fc521197 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -71,8 +71,7 @@ rhino                  = "1.7.7.2"
 
 # TESTING
 
-junit5 = "5.14.2"
-junit4 = "4.13.2"
+junit5 = "5.14.3"
 
 testng   = "7.5.1"
 easymock = "5.6.0"
@@ -198,9 +197,8 @@ junit-jupiter-engine    = { module = 
"org.junit.jupiter:junit-jupiter-engine" }
 junit-vintage-engine    = { module = "org.junit.vintage:junit-vintage-engine" }
 junit-platform-launcher = { module = 
"org.junit.platform:junit-platform-launcher" }
 
-junit4 = { module = "junit:junit", version.ref = "junit4" }
-
-testng = { module = "org.testng:testng", version.ref = "testng" }
+testng        = { module = "org.testng:testng", version.ref = "testng" }
+testng-engine = { module = "org.junit.support:testng-engine", version = 
"1.1.0" }
 
 easymock = { module = "org.easymock:easymock", version.ref = "easymock" }
 
diff --git a/quickstart/build.gradle b/quickstart/build.gradle
index 76bdbfb39..199f93414 100644
--- a/quickstart/build.gradle
+++ b/quickstart/build.gradle
@@ -2,7 +2,7 @@ import org.apache.tools.ant.filters.FixCrLfFilter
 import org.apache.tools.ant.filters.ReplaceTokens
 
 tasks.register('copyGradleWrapper', Copy) {
-    group = 'Build Setup'
+    group = 'build setup'
     description = 'Copies Gradle wrapper files to archetype resources'
 
     def srcDir = layout.buildDirectory.dir('wrapper')
@@ -15,7 +15,7 @@ tasks.register('copyGradleWrapper', Copy) {
 }
 
 tasks.register('addGradleWrapper', Exec) {
-    group = 'Build Setup'
+    group = 'build setup'
     description = 'Generates Gradle wrapper in temporary directory'
 
     workingDir = layout.buildDirectory.dir('wrapper')
@@ -34,14 +34,14 @@ tasks.register('addGradleWrapper', Exec) {
 }
 
 tasks.register('addWrappers') {
-    group = 'Build Setup'
+    group = 'build setup'
     description = 'Coordinates wrapper generation tasks'
 
     dependsOn addGradleWrapper
 }
 
 tasks.register('processFiltered', Copy) {
-    group = 'Build'
+    group = 'build'
     description = 'Processes filtered resources with token replacement'
 
     def srcDir = layout.projectDirectory.dir('src/main/resources-filtered')
diff --git a/settings.gradle b/settings.gradle
index 1cc4d555c..46841ef36 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -10,7 +10,10 @@ dependencyResolutionManagement {
         mavenLocal {
             content {
                 // https://github.com/gradle/gradle/issues/18519
-                excludeGroup "io.netty"
+                // Netty-related dependencies are os/arch-dependent, but 
gradle might fail
+                // to resolve the correct one if finding anything in the local 
Maven, as
+                // it stops looking further.
+                excludeGroup 'io.netty'
             }
         }
         mavenCentral()
diff --git a/tapestry-beanvalidator/build.gradle 
b/tapestry-beanvalidator/build.gradle
index bee604967..183d0027a 100644
--- a/tapestry-beanvalidator/build.gradle
+++ b/tapestry-beanvalidator/build.gradle
@@ -2,8 +2,6 @@ plugins {
     id 'tapestry.testng-convention'
 }
 
-import t5build.*
-
 description = 'Support for JSR-303 Bean Validation via the Hibernate validator 
implementation'
 
 dependencies {
@@ -27,6 +25,21 @@ tasks.register('runTestApp303', JavaExec) {
     classpath += project.sourceSets.test.runtimeClasspath
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.beanvalidator.modules.BeanValidatorModule'
diff --git a/tapestry-cdi/build.gradle b/tapestry-cdi/build.gradle
index 85bf10ac8..ce32f4d26 100644
--- a/tapestry-cdi/build.gradle
+++ b/tapestry-cdi/build.gradle
@@ -1,5 +1,4 @@
 import org.gradle.plugins.ide.idea.model.*
-import t5build.*
 
 plugins {
     id 'tapestry.junit4-legacy-convention'
diff --git a/tapestry-core/build.gradle b/tapestry-core/build.gradle
index 06cd89d35..0b0afd063 100644
--- a/tapestry-core/build.gradle
+++ b/tapestry-core/build.gradle
@@ -1,18 +1,15 @@
-import org.gradle.api.tasks.testing.Test
-import t5build.TapestryBuildLogic
-import org.apache.tools.ant.filters.ReplaceTokens
-
 plugins {
-    id 'tapestry.testing-base-convention'
     id 'tapestry.junit5-convention'
     id 'tapestry.testng-convention'
 }
 
-
 description = 'Central module for Tapestry, containing all core services and 
components'
 
 def npmWorkingDir = 'src/main/typescript/'
-def npmExec = TapestryBuildLogic.isWindows() ? 'npm.cmd' : 'npm'
+def npmExec = 'npm'
+if (System.properties['os.name'].toLowerCase().contains('windows')) {
+    npmExec += '.cmd'
+}
 
 dependencies {
     api project(':tapestry-ioc')
@@ -34,11 +31,11 @@ dependencies {
     testImplementation libs.commons.httpclient
     testImplementation project(":tapestry-spock")
 
-    
testRuntimeOnly("${libs.hsqldb.get().module.group}:${libs.hsqldb.get().module.name}:${libs.hsqldb.get().version}:jdk8")
+    testRuntimeOnly libs.hsqldb
 }
 
 tasks.register('npmInstall', Exec) {
-    group = 'TypeScript'
+    group = 'typescript'
     description = 'Runs npm install'
     
     workingDir = npmWorkingDir
@@ -46,7 +43,7 @@ tasks.register('npmInstall', Exec) {
 }
 
 tasks.register('compileTypeScriptToAmd', Exec) {
-    group = 'TypeScript'
+    group = 'typescript'
     description = 'Compiles TypeScript to AMD modules'
 
     dependsOn npmInstall
@@ -56,7 +53,7 @@ tasks.register('compileTypeScriptToAmd', Exec) {
 }
 
 tasks.register('compileTypeScriptToEsModule', Exec) {
-    group = 'TypeScript'
+    group = 'typescript'
     description = 'Compiles TypeScript to ES modules'
 
     dependsOn npmInstall
@@ -65,8 +62,11 @@ tasks.register('compileTypeScriptToEsModule', Exec) {
     commandLine npmExec, 'run', 'build-es-module'
 }
 
+// Lifecycle task that triggers both TypeScript compilations.
+// Typed as Delete only because Gradle requires a concrete type
+// and this task performs no actions of its own.
 tasks.register('compileTypeScript', Delete) {
-    group = 'TypeScript'
+    group = 'typescript'
     description = 'Compiles all TypeScript variants'
 
     dependsOn compileTypeScriptToAmd, compileTypeScriptToEsModule
@@ -77,7 +77,7 @@ tasks.named('sourcesJar') {
 }
 
 tasks.register('generateTypeScriptDocs', Exec) {
-    group = 'TypeScript'
+    group = 'typescript'
     description = 'Generates TypeScript documentation'
 
     dependsOn npmInstall
@@ -87,7 +87,7 @@ tasks.register('generateTypeScriptDocs', Exec) {
 }
 
 tasks.register('cleanTypeScriptFiles', Delete) {
-    group = 'TypeScript'
+    group = 'typescript'
     description = 'Cleans generated TypeScript files'
 
     delete fileTree('src/main/resources/META-INF/assets/es-modules/t5/core') {
@@ -108,12 +108,18 @@ tasks.named('clean') {
 }
 
 
-// Not sure why this is necessary:
+// Groovy test classes extend Java test base classes, so Java must be compiled 
first.
+// Gradle does not infer this ordering automatically in mixed-language source 
sets.
 tasks.named('compileTestGroovy') {
     dependsOn compileTestJava
 }
 
 test {
+    useJUnitPlatform()
+
+    // Don't run integration tests via Jupiter testng-runner
+    exclude '**/integration/**'
+
     // Needed to have XMLTokenStreamTests.testStreamEncoding() passing on Java 
9+
     jvmArgs('--add-opens=java.base/java.nio.charset=ALL-UNNAMED')
 
@@ -121,6 +127,19 @@ test {
     systemProperty 'user.language', 'en'
 }
 
+// This task runs the default integration tests that are defined in the 
testng.xml
+tasks.register('testNG', Test) {
+    group = 'verification'
+
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 [
     'app1',
     'app2',
@@ -131,7 +150,7 @@ test {
     'appfolder'
 ].each { appName ->
     tasks.register("runTest${appName.capitalize()}", JavaExec) {
-        group = 'Application'
+        group = 'application'
         description = "Starts the ${appName} integration test app, useful when 
debugging."
 
         mainClass = 'org.apache.tapestry5.test.JettyRunner'
@@ -140,65 +159,16 @@ test {
     }
 }
 
-// Default is jQuery and Require.js enabled, so here are the tests for the
-// other combinations.
-// Tests need to run both JUnit and TestNG, so we need to register both
-// and connect them all.
-
-def registerTestPair(String baseName, Closure configuration) {
-
-    // Logic to configure a task with the user-provided closure
-    def configureTask = { Test task ->
-        // Create a copy of the closure to avoid side effects
-        Closure configClone = configuration.clone()
-        configClone.delegate = task
-        configClone.resolveStrategy = Closure.DELEGATE_FIRST
-        configClone.call()
-    }
-
-    def jUnit = tasks.register("${baseName}JUnit", Test) {
-        group = 'verification'
-        description = "Runs JUnit tests for ${baseName}"
+// The default testNG run uses jQuery + Require.js (the most common production 
config).
+// These three tasks exercise the other combinations. They are NOT wired into 
'check'
+// and run only via the 'continuousIntegration' task in the root build.
 
-        useJUnitPlatform()
+tasks.register('testWithJqueryAndRequireJsDisabled', Test) {
+    group = 'verification'
+    description = 'Runs tests with jQuery and Require.js disabled'
 
-        binaryResultsDirectory = 
layout.buildDirectory.dir("test-results/$name")
-        reports.html.outputLocation = 
layout.buildDirectory.dir("reports/tests/$name")
+    useTestNG()
 
-        configureTask(it)
-    }
-
-    def testNG = tasks.register("${baseName}TestNG", Test) {
-        group = 'verification'
-        description = "Rest TestNG tests for ${baseName}"
-
-        // Logic to find TestNG suite file
-        def suiteFile = [
-            'src/test/resources/testng.xml',
-            'src/test/conf/testng.xml'
-        ].collect { layout.projectDirectory.file(it).asFile }.find { 
it.exists() }
-
-        useTestNG {
-            if (suiteFile) {
-                suites(suiteFile)
-            }
-        }
-
-        binaryResultsDirectory = 
layout.buildDirectory.dir("test-results/$name")
-        reports.html.outputLocation = 
layout.buildDirectory.dir("reports/tests/$name")
-
-        configureTask(it)
-    }
-
-    tasks.register(baseName) {
-        group = 'verification'
-        description = "Runs JUnit and TestNG tests for ${baseName}"
-
-        dependsOn jUnit, testNG
-    }
-}
-
-registerTestPair('testWithJqueryAndRequireJsDisabled') {
     systemProperties(
         'tapestry.javascript-infrastructure-provider': 'jquery',
         'tapestry.require-js-enabled':                 'false',
@@ -207,7 +177,11 @@ registerTestPair('testWithJqueryAndRequireJsDisabled') {
     )
 }
 
-registerTestPair('testWithPrototypeAndRequireJsEnabled') {
+tasks.register('testWithPrototypeAndRequireJsEnabled', Test) {
+    group = 'verification'  
+    description = 'Runs tests with Prototype and Require.js enabled'
+    useTestNG()
+
     systemProperties(
         'tapestry.javascript-infrastructure-provider': 'prototype',
         'tapestry.require-js-enabled':                 'true',
@@ -216,7 +190,12 @@ registerTestPair('testWithPrototypeAndRequireJsEnabled') {
     )
 }
 
-registerTestPair('testWithPrototypeAndRequireJsDisabled') {
+tasks.register('testWithPrototypeAndRequireJsDisabled', Test) {
+    group = 'verification'
+    description = 'Runs tests with Prototype and Require.js disabled'
+
+    useTestNG()
+
     systemProperties(
         'tapestry.javascript-infrastructure-provider': 'prototype',
         'tapestry.require-js-enabled':                 'false',
diff --git a/tapestry-core/src/test/resources/testng.xml 
b/tapestry-core/src/test/resources/testng.xml
index e7ffac3f7..b46d6f256 100644
--- a/tapestry-core/src/test/resources/testng.xml
+++ b/tapestry-core/src/test/resources/testng.xml
@@ -11,50 +11,6 @@
     name="Tapestry Core"
     annotations="1.5"
     verbose="2">
-    <test
-        name="Component Unit Tests"
-        enabled="true">
-        <packages>
-            <package name="org.apache.tapestry5.pagetester" />
-            <package name="org.apache.tapestry5.integration.pagelevel" />
-            <package name="org.apache.tapestry5.corelib.base" />
-            <package name="org.apache.tapestry5.corelib.components" />
-            <package name="org.apache.tapestry5.corelib.mixins" />
-            <package name="org.apache.tapestry5.corelib.internal" />
-        </packages>
-    </test>
-
-    <test
-        name="Service Unit Tests"
-        enabled="true">
-        <packages>
-            <package name="org.apache.tapestry5.root" />
-            <package name="org.apache.tapestry5.dom" />
-            <package name="org.apache.tapestry5.runtime" />
-            <package name="org.apache.tapestry5.services" />
-            <package name="org.apache.tapestry5.services.javascript" />
-            <package name="org.apache.tapestry5.util" />
-            <package name="org.apache.tapestry5.validator" />
-            <package name="org.apache.tapestry5.internal" />
-            <package name="org.apache.tapestry5.internal.beaneditor" />
-            <package name="org.apache.tapestry5.internal.bindings" />
-            <package name="org.apache.tapestry5.internal.grid" />
-            <package name="org.apache.tapestry5.internal.model" />
-            <package name="org.apache.tapestry5.internal.pageload" />
-            <package name="org.apache.tapestry5.internal.services" />
-            <package name="org.apache.tapestry5.internal.services.assets" />
-            <package name="org.apache.tapestry5.internal.services.messages" />
-            <package name="org.apache.tapestry5.internal.services.meta" />
-            <package name="org.apache.tapestry5.internal.services.templates" />
-            <package name="org.apache.tapestry5.internal.structure" />
-            <package name="org.apache.tapestry5.internal.test" />
-            <package name="org.apache.tapestry5.internal.transform" />
-            <package name="org.apache.tapestry5.internal.translator" />
-            <package name="org.apache.tapestry5.internal.util" />
-            <package name="org.apache.tapestry5.internal.services.ajax" />
-        </packages>
-    </test>
-
     <!-- We break these out by which of the test applications they apply to. 
We have a bunch. -->
     <test
         name="Core Integration Tests"
diff --git a/tapestry-hibernate-core/build.gradle 
b/tapestry-hibernate-core/build.gradle
index 48aa01612..60ef293d3 100644
--- a/tapestry-hibernate-core/build.gradle
+++ b/tapestry-hibernate-core/build.gradle
@@ -12,7 +12,7 @@ dependencies {
     implementation libs.jaxb.runtime
 
     testImplementation project(':tapestry-test')
-    
testRuntimeOnly("${libs.hsqldb.get().module.group}:${libs.hsqldb.get().module.name}:${libs.hsqldb.get().version}:jdk8")
+    testRuntimeOnly libs.hsqldb
 }
 
 jar {
diff --git a/tapestry-hibernate/build.gradle b/tapestry-hibernate/build.gradle
index f6e08dd8b..a2a9d32d7 100644
--- a/tapestry-hibernate/build.gradle
+++ b/tapestry-hibernate/build.gradle
@@ -11,7 +11,7 @@ dependencies {
     implementation libs.jboss.logging
 
     testImplementation project(':tapestry-test')
-    
testRuntimeOnly("${libs.hsqldb.get().module.group}:${libs.hsqldb.get().module.name}:${libs.hsqldb.get().version}:jdk8")
+    testRuntimeOnly libs.hsqldb
 }
 
 tasks.register('runTestApp0', JavaExec) {
@@ -22,6 +22,21 @@ tasks.register('runTestApp0', JavaExec) {
     classpath += project.sourceSets.test.runtimeClasspath
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.hibernate.web.modules.HibernateModule'
diff --git a/tapestry-hibernate/src/test/resources/testng.xml 
b/tapestry-hibernate/src/test/resources/testng.xml
index 58f4ffac1..d18fa8361 100644
--- a/tapestry-hibernate/src/test/resources/testng.xml
+++ b/tapestry-hibernate/src/test/resources/testng.xml
@@ -17,11 +17,6 @@
 
 <suite name="Tapestry Hibernate" parallel="false" thread-count="10" 
annotations="1.5" verbose="2">
     <parameter name="tapestry.integration-webapp" value="src/test/webapp"/>
-    <test name="Tapestry Hibernate Internal APIs">
-        <packages>
-            <package name="org.apache.tapestry5.hibernate.web.internal"/>
-        </packages>
-    </test>
     <test name="Tapestry Hibernate Integration Tests">
         <packages>
             <package name="org.apache.tapestry5.hibernate.web.integration"/>
diff --git a/tapestry-http/build.gradle b/tapestry-http/build.gradle
index edcdc56fa..c4d535e30 100644
--- a/tapestry-http/build.gradle
+++ b/tapestry-http/build.gradle
@@ -19,7 +19,7 @@ dependencies {
     testImplementation project(':tapestry-test')
     testImplementation project(':tapestry-test-constants')
 
-    
testRuntimeOnly("${libs.hsqldb.get().module.group}:${libs.hsqldb.get().module.name}:${libs.hsqldb.get().version}:jdk8")
+    testRuntimeOnly libs.hsqldb
     testRuntimeOnly libs.slf4j.simple
 }
 
@@ -33,3 +33,18 @@ tasks.named('jar', Jar) {
         filter(ReplaceTokens, tokens: [version: project.parent.version])
     }
 }
+
+tasks.named('test') {
+    exclude '**/TapestryHttpIntegrationTests*'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
diff --git a/tapestry-http/src/test/resources/testng.xml 
b/tapestry-http/src/test/resources/testng.xml
new file mode 100644
index 000000000..edd62c552
--- /dev/null
+++ b/tapestry-http/src/test/resources/testng.xml
@@ -0,0 +1,11 @@
+<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd";>
+
+<suite name="Tapestry HTTP" annotations="1.5" verbose="2">
+
+    <test name="Tapestry HTTP Integration Tests" enabled="true">
+        <packages>
+            <package name="org.apache.tapestry5.http.test" />
+        </packages>
+    </test>
+
+</suite>
diff --git a/tapestry-ioc/build.gradle b/tapestry-ioc/build.gradle
index 722487afe..ba8d0d8b2 100644
--- a/tapestry-ioc/build.gradle
+++ b/tapestry-ioc/build.gradle
@@ -18,7 +18,7 @@ dependencies {
 
     testImplementation libs.commons.lang3
     testImplementation libs.hibernate.core
-    
testRuntimeOnly("${libs.hsqldb.get().module.group}:${libs.hsqldb.get().module.name}:${libs.hsqldb.get().version}:jdk8")
+    testRuntimeOnly libs.hsqldb
 }
 
 tasks.named('test', Test) {
diff --git a/tapestry-jmx/build.gradle b/tapestry-jmx/build.gradle
index 00694090c..b4d80f37e 100644
--- a/tapestry-jmx/build.gradle
+++ b/tapestry-jmx/build.gradle
@@ -10,6 +10,21 @@ dependencies {
     testImplementation project(':tapestry-test')
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 jar {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.jmx.modules.JmxModule'
diff --git a/tapestry-jpa/build.gradle b/tapestry-jpa/build.gradle
index 0f7ff48e5..7b0b7121d 100644
--- a/tapestry-jpa/build.gradle
+++ b/tapestry-jpa/build.gradle
@@ -26,6 +26,21 @@ dependencies {
     }
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.jpa.modules.JpaModule'
diff --git 
a/tapestry-jpa/src/test/java/org/apache/tapestry5/internal/jpa/JpaTransactionAdvisorImplTest.java
 
b/tapestry-jpa/src/test/java/org/apache/tapestry5/internal/jpa/JpaTransactionAdvisorImplTest.java
index 558ea8dbc..e766b7276 100644
--- 
a/tapestry-jpa/src/test/java/org/apache/tapestry5/internal/jpa/JpaTransactionAdvisorImplTest.java
+++ 
b/tapestry-jpa/src/test/java/org/apache/tapestry5/internal/jpa/JpaTransactionAdvisorImplTest.java
@@ -37,6 +37,9 @@ import org.testng.annotations.AfterClass;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
 
+// Disabled as JUnit would pick it up, but TestNG ignored it due to missing in 
testng.xml
+// 
https://github.com/apache/tapestry-5/commit/246e677097f249ad2352ddc595ef43cc257f8e27
+@Test(enabled = false)
 public class JpaTransactionAdvisorImplTest extends IOCTestCase
 {
     private static final String UNIT_NAME = "FooUnit";
@@ -62,7 +65,7 @@ public class JpaTransactionAdvisorImplTest extends IOCTestCase
         registry = null;
     }
 
-    @Test
+    @Test(enabled = false)
     public void undecorated()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -84,7 +87,7 @@ public class JpaTransactionAdvisorImplTest extends IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void persistence_unit_name_missing()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -118,7 +121,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void persistence_unit_name_missing_single_unit_configured()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -147,7 +150,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void persistence_unit_missing()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -181,7 +184,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void persistence_unit_missing_single_unit_configured()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -210,7 +213,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void transaction_inactive()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -238,7 +241,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void void_method()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -266,7 +269,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void void_method_with_param()
     {
         final VoidService delegate = newMock(VoidService.class);
@@ -292,7 +295,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void runtime_exception_will_abort_transaction() throws Exception
     {
         final Performer delegate = newMock(Performer.class);
@@ -328,7 +331,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void checked_exception_will_commit_transaction() throws Exception
     {
         final Performer delegate = newMock(Performer.class);
@@ -369,7 +372,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void return_type_method()
     {
         final ReturnTypeService delegate = newTestService();
@@ -395,7 +398,7 @@ public class JpaTransactionAdvisorImplTest extends 
IOCTestCase
         verify();
     }
 
-    @Test
+    @Test(enabled = false)
     public void return_type_method_with_param()
     {
         final ReturnTypeService delegate = newTestService();
diff --git 
a/tapestry-jpa/src/test/java/org/apache/tapestry5/jpa/test/JpaTest.java 
b/tapestry-jpa/src/test/java/org/apache/tapestry5/jpa/test/JpaTest.java
index 6c7493f0d..9a27ae889 100644
--- a/tapestry-jpa/src/test/java/org/apache/tapestry5/jpa/test/JpaTest.java
+++ b/tapestry-jpa/src/test/java/org/apache/tapestry5/jpa/test/JpaTest.java
@@ -47,6 +47,9 @@ import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+// Disabled as JUnit would pick it up, but TestNG ignored it due to missing in 
testng.xml
+// 
https://github.com/apache/tapestry-5/commit/246e677097f249ad2352ddc595ef43cc257f8e27
+@Test(enabled = false)
 public class JpaTest
 {
 
@@ -149,7 +152,7 @@ public class JpaTest
         return em.createQuery(query).getResultList();
     }
 
-    @Test
+    @Test(enabled = false)
     public void commitBothInNestedTransaction()
     {
         topLevelService.createThingOneAndTwo("one", "two");
@@ -158,19 +161,19 @@ public class JpaTest
         assertTrue(getEntityManager().find(VersionedThing.class, 
1).getVersion() > 0);
     }
 
-    @Test(expectedExceptions = RollbackException.class)
+    @Test(expectedExceptions = RollbackException.class, enabled = false)
     public void rollbackNestedFails()
     {
         topLevelService.createThingOneAndTwo("one", null);
     }
 
-    @Test(expectedExceptions = RollbackException.class)
+    @Test(expectedExceptions = RollbackException.class, enabled = false)
     public void rollbackTopFails()
     {
         topLevelService.createThingOneAndTwo(null, "two");
     }
 
-    @Test
+    @Test(enabled = false)
     public void sequentialCommitUsingInvokeAfterCommit()
     {
         topLevelService.createThingOneThenTwo("one", "two");
@@ -179,7 +182,7 @@ public class JpaTest
         assertTrue(getEntityManager().find(VersionedThing.class, 
1).getVersion() > 1);
     }
 
-    @Test
+    @Test(enabled = false)
     public void 
sequentialCommitUsingInvokeAfterCommitAndCommitAfterAnnotation()
     {
         topLevelService.createThingOneThenTwoWithNestedCommitAfter("one", 
"two");
@@ -188,7 +191,7 @@ public class JpaTest
         assertTrue(getEntityManager().find(VersionedThing.class, 
1).getVersion() > 1);
     }
 
-    @Test
+    @Test(enabled = false)
     public void sequentialRollbackAndAbortUsingInvokeAfterCommit()
     {
         try
@@ -203,7 +206,7 @@ public class JpaTest
         assertEquals(0, getInstances(ThingTwo.class).size());
     }
 
-    @Test
+    @Test(enabled = false)
     public void trySomething()
     {
         ThingOne thingOne = new ThingOne();
diff --git a/tapestry-kaptcha/build.gradle b/tapestry-kaptcha/build.gradle
index 355af5daa..df8ee9637 100644
--- a/tapestry-kaptcha/build.gradle
+++ b/tapestry-kaptcha/build.gradle
@@ -14,6 +14,21 @@ dependencies {
     testImplementation project(':tapestry-test')
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.kaptcha.modules.KaptchaModule'
diff --git 
a/tapestry-kaptcha/src/test/java/org/apache/tapestry5/kaptcha/components/KaptchaUnitTest.java
 
b/tapestry-kaptcha/src/test/java/org/apache/tapestry5/kaptcha/components/KaptchaUnitTest.java
index 6346c1653..87d756af2 100644
--- 
a/tapestry-kaptcha/src/test/java/org/apache/tapestry5/kaptcha/components/KaptchaUnitTest.java
+++ 
b/tapestry-kaptcha/src/test/java/org/apache/tapestry5/kaptcha/components/KaptchaUnitTest.java
@@ -20,13 +20,13 @@ import org.apache.tapestry5.test.PageTester;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
-@Test
+@Test(enabled = false)
 public class KaptchaUnitTest
 {
     
     @SuppressWarnings("unchecked")
 
-    @Test
+    @Test(enabled = false)
     public void cache_control_header()
     {
         PageTester tester = new PageTester("kaptcha.demo", "app");
diff --git a/tapestry-kaptcha/src/test/resources/testng.xml 
b/tapestry-kaptcha/src/test/resources/testng.xml
index 1875fe4ad..ac2e62756 100644
--- a/tapestry-kaptcha/src/test/resources/testng.xml
+++ b/tapestry-kaptcha/src/test/resources/testng.xml
@@ -3,7 +3,6 @@
   <test name="Integration Tests">
     <parameter name="tapestry.web-app-folder" value="src/test/webapp"/>
     <packages>
-      <package name="org.apache.tapestry5.kaptcha"/>
       <package name="org.apache.tapestry5.kaptcha.integration"/>
     </packages>
   </test>
diff --git a/tapestry-spring/build.gradle b/tapestry-spring/build.gradle
index 9d069604c..0b412cd7b 100644
--- a/tapestry-spring/build.gradle
+++ b/tapestry-spring/build.gradle
@@ -31,7 +31,7 @@ dependencies {
 
 ['', '1'].each { suffix ->
     tasks.register("runTestApp${suffix}", JavaExec) {
-        group = 'Application'
+        group = 'application'
         description = "Start tapestry-spring integration test app${suffix}, 
useful when debugging failing integration tests"
         mainClass = 'org.apache.tapestry5.test.JettyRunner'
         args '-d', "src/test/webapp${suffix}", '-p', '8080'
@@ -39,6 +39,21 @@ dependencies {
     }
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.spring.modules.SpringModule'
diff --git a/tapestry-spring/src/test/resources/testng.xml 
b/tapestry-spring/src/test/resources/testng.xml
index 076743557..d40d02bf2 100644
--- a/tapestry-spring/src/test/resources/testng.xml
+++ b/tapestry-spring/src/test/resources/testng.xml
@@ -16,12 +16,6 @@
 -->
 
 <suite name="Tapesty Spring Integration" parallel="false" thread-count="10" 
annotations="1.5" verbose="2">
-    <test name="Tapestry Spring Unit Tests">
-        <packages>
-            <package name="org.apache.tapestry5.spring"/>
-            <package name="org.apache.tapestry5.internal.spring"/>
-        </packages>
-    </test>
     <test name="Tapestry Spring Integration - Standard">
         <packages>
             <package name="org.apache.tapestry5.spring.integration.standard"/>
diff --git a/tapestry-test/build.gradle b/tapestry-test/build.gradle
index 70b34c377..281b3f9c0 100644
--- a/tapestry-test/build.gradle
+++ b/tapestry-test/build.gradle
@@ -25,3 +25,18 @@ dependencies {
     api libs.testng
     api libs.easymock
 }
+
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
diff --git a/tapestry-upload/build.gradle b/tapestry-upload/build.gradle
index fdd1458d1..83dc5e204 100644
--- a/tapestry-upload/build.gradle
+++ b/tapestry-upload/build.gradle
@@ -16,6 +16,21 @@ dependencies {
     testImplementation project(':tapestry-test')
 }
 
+tasks.named('test') {
+    exclude '**/integration/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    useTestNG {
+        suiteXmlFiles << project.file('src/test/resources/testng.xml')
+    }
+}
+
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.upload.modules.UploadModule'
diff --git a/tapestry-upload/src/test/resources/testng.xml 
b/tapestry-upload/src/test/resources/testng.xml
index a6ed19248..d6d82a9ec 100755
--- a/tapestry-upload/src/test/resources/testng.xml
+++ b/tapestry-upload/src/test/resources/testng.xml
@@ -19,8 +19,6 @@
     <test name="Tapesty Upload Component">
         <parameter name="tapestry.web-app-folder" value="src/test/webapp"/>
         <packages>
-            <package name="org.apache.tapestry5.upload.components"/>
-            <package name="org.apache.tapestry5.upload.internal.services"/>
             <package name="org.apache.tapestry5.upload.integration"/>
         </packages>
     </test>
diff --git a/tapestry-webresources/build.gradle 
b/tapestry-webresources/build.gradle
index 4588f1334..4082fd0e3 100644
--- a/tapestry-webresources/build.gradle
+++ b/tapestry-webresources/build.gradle
@@ -24,7 +24,19 @@ dependencies {
 }
 
 
-test {
+tasks.named('test') {
+    useJUnitPlatform()
+    exclude '**/services/web/**'
+}
+
+tasks.register('testNG', Test) {
+    group = 'verification'
+    description = 'Runs Selenium integration tests via native TestNG'
+
+    useTestNG()
+
+    include '**/services/web/**'
+
     systemProperties(
         'tapestry.page-load-timeout': '60',
         'tapestry.compiled-asset-cache-dir': "$buildDir/compiled-asset-cache",
@@ -34,6 +46,10 @@ test {
     )
 }
 
+tasks.named('check') {
+    dependsOn 'testNG'
+}
+
 tasks.named('jar', Jar) {
     manifest {
         attributes 'Tapestry-Module-Classes': 
'org.apache.tapestry5.webresources.modules.WebResourcesModule'

Reply via email to