JAL-4159 fix up test for applicable filters for pasimap
[jalview.git] / build.gradle
index 9161ed2..6df3ec9 100644 (file)
@@ -9,6 +9,11 @@ import org.gradle.util.ConfigureUtil
 import org.gradle.plugins.ide.eclipse.model.Output
 import org.gradle.plugins.ide.eclipse.model.Library
 import java.security.MessageDigest
+import java.util.regex.Matcher
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.ScheduledExecutorService
+import java.util.concurrent.TimeUnit
 import groovy.transform.ExternalizeMethods
 import groovy.util.XmlParser
 import groovy.xml.XmlUtil
@@ -37,6 +42,7 @@ buildscript {
   dependencies {
     classpath "com.vladsch.flexmark:flexmark-all:0.62.0"
     classpath "org.jsoup:jsoup:1.14.3"
+    classpath "com.eowise:gradle-imagemagick:0.5.1"
   }
 }
 
@@ -45,10 +51,10 @@ plugins {
   id 'java'
   id 'application'
   id 'eclipse'
-  id "com.diffplug.spotless" version "6.18.0" //.gradle.spotless" "3.28.0"
-  id 'com.github.johnrengelman.shadow' version '8.1.1' // was 4.0.3
-  id 'com.install4j.gradle' version '9.0.6'
-  id 'com.dorongold.task-tree' version '2.1.0' // only needed to display task dependency tree with  gradle task1 [task2 ...] taskTree
+  id "com.diffplug.gradle.spotless" version "3.28.0"
+  id 'com.github.johnrengelman.shadow' version '6.0.0'
+  id 'com.install4j.gradle' version '10.0.3'
+  id 'com.dorongold.task-tree' version '2.1.1' // only needed to display task dependency tree with  gradle task1 [task2 ...] taskTree
   id 'com.palantir.git-version' version '0.13.0' apply false
 }
 
@@ -108,8 +114,12 @@ ext {
   getdownChannelName = CHANNEL.toLowerCase()
   // default to "default". Currently only has different cosmetics for "develop", "release", "default"
   propertiesChannelName = ["develop", "release", "test-release", "jalviewjs", "jalviewjs-release" ].contains(getdownChannelName) ? getdownChannelName : "default"
+  channelDirName = propertiesChannelName
   // Import channel_properties
-  channelDir = string("${jalviewDir}/${channel_properties_dir}/${propertiesChannelName}")
+  if (getdownChannelName.startsWith("develop-")) {
+    channelDirName = "develop-SUFFIX"
+  }
+  channelDir = string("${jalviewDir}/${channel_properties_dir}/${channelDirName}")
   channelGradleProperties = string("${channelDir}/channel_gradle.properties")
   channelPropsFile = string("${channelDir}/${resource_dir}/${channel_props}")
   overrideProperties(channelGradleProperties, false)
@@ -183,7 +193,6 @@ ext {
   testDir = string("${jalviewDir}/${bareTestSourceDir}")
 
   classesDir = string("${jalviewDir}/${classes_dir}")
-  destinationDirectory = file(classesDir)
 
   // clover
   useClover = clover.equals("true")
@@ -199,6 +208,8 @@ ext {
   testSourceDir = useClover ? cloverTestInstrDir : testDir
   testClassesDir = useClover ? cloverTestClassesDir : "${jalviewDir}/${test_output_dir}"
 
+  channelSuffix = ""
+  backgroundImageText = BACKGROUNDIMAGETEXT
   getdownChannelDir = string("${getdown_website_dir}/${propertiesChannelName}")
   getdownAppBaseDir = string("${jalviewDir}/${getdownChannelDir}/${JAVA_VERSION}")
   getdownArchiveDir = string("${jalviewDir}/${getdown_archive_dir}")
@@ -216,12 +227,15 @@ ext {
   getdownLauncher = string("${jalviewDir}/${getdown_lib_dir}/${getdown_launcher}")
   getdownAppDistDir = getdown_app_dir_alt
   getdownImagesDir = string("${jalviewDir}/${getdown_images_dir}")
+  getdownImagesBuildDir = string("${buildDir}/imagemagick/getdown")
   getdownSetAppBaseProperty = false // whether to pass the appbase and appdistdir to the application
   reportRsyncCommand = false
   jvlChannelName = CHANNEL.toLowerCase()
   install4jSuffix = CHANNEL.substring(0, 1).toUpperCase() + CHANNEL.substring(1).toLowerCase(); // BUILD -> Build
   install4jDMGDSStore = "${install4j_images_dir}/${install4j_dmg_ds_store}"
-  install4jDMGBackgroundImage = "${install4j_images_dir}/${install4j_dmg_background}"
+  install4jDMGBackgroundImageDir = "${install4j_images_dir}"
+  install4jDMGBackgroundImageBuildDir = "build/imagemagick/install4j"
+  install4jDMGBackgroundImageFile = "${install4j_dmg_background}"
   install4jInstallerName = "${jalview_name} Non-Release Installer"
   install4jExecutableName = install4j_executable_name
   install4jExtraScheme = "jalviewx"
@@ -246,6 +260,7 @@ ext {
       testng_excluded_groups = "Not-bamboo"
     }
     install4jExtraScheme = "jalviewb"
+    backgroundImageText = true
     break
 
     case [ "RELEASE", "JALVIEWJS-RELEASE" ]:
@@ -276,7 +291,7 @@ ext {
     getdownDir = string("${getdownChannelName}/${JAVA_VERSION}")
     getdownAppBase = file(getdownAppBaseDir).toURI().toString()
     if (!file("${ARCHIVEDIR}/${package_dir}").exists()) {
-      throw new GradleException("Must provide an ARCHIVEDIR value to produce an archive distribution")
+      throw new GradleException("Must provide an ARCHIVEDIR value to produce an archive distribution [did not find '${ARCHIVEDIR}/${package_dir}']")
     } else {
       package_dir = string("${ARCHIVEDIR}/${package_dir}")
       buildProperties = string("${ARCHIVEDIR}/${classes_dir}/${build_properties_file}")
@@ -288,6 +303,23 @@ ext {
     install4jExtraScheme = "jalviewa"
     break
 
+    case ~/^DEVELOP-([\.\-\w]*)$/:
+    def suffix = Matcher.lastMatcher[0][1]
+    reportRsyncCommand = true
+    getdownSetAppBaseProperty = true
+    JALVIEW_VERSION=JALVIEW_VERSION+"-d${suffix}-${buildDate}"
+    install4jSuffix = "Develop ${suffix}"
+    install4jExtraScheme = "jalviewd"
+    install4jInstallerName = "${jalview_name} Develop ${suffix} Installer"
+    getdownChannelName = string("develop-${suffix}")
+    getdownChannelDir = string("${getdown_website_dir}/${getdownChannelName}")
+    getdownAppBaseDir = string("${jalviewDir}/${getdownChannelDir}/${JAVA_VERSION}")
+    getdownDir = string("${getdownChannelName}/${JAVA_VERSION}")
+    getdownAppBase = string("${getdown_channel_base}/${getdownDir}")
+    channelSuffix = string(suffix)
+    backgroundImageText = true
+    break
+
     case "DEVELOP":
     reportRsyncCommand = true
     getdownSetAppBaseProperty = true
@@ -297,6 +329,7 @@ ext {
     install4jSuffix = "Develop"
     install4jExtraScheme = "jalviewd"
     install4jInstallerName = "${jalview_name} Develop Installer"
+    backgroundImageText = true
     break
 
     case "TEST-RELEASE":
@@ -311,6 +344,7 @@ ext {
     install4jSuffix = "Test"
     install4jExtraScheme = "jalviewt"
     install4jInstallerName = "${jalview_name} Test Installer"
+    backgroundImageText = true
     break
 
     case ~/^SCRATCH(|-[-\w]*)$/:
@@ -334,6 +368,7 @@ ext {
     install4jSuffix = "Test-Local"
     install4jExtraScheme = "jalviewt"
     install4jInstallerName = "${jalview_name} Test Installer"
+    backgroundImageText = true
     break
 
     case [ "LOCAL", "JALVIEWJS" ]:
@@ -484,16 +519,10 @@ ext {
   // for install4j
   JAVA_MIN_VERSION = JAVA_VERSION
   JAVA_MAX_VERSION = JAVA_VERSION
-  def jreInstallsDir = string(jre_installs_dir)
+  jreInstallsDir = string(jre_installs_dir)
   if (jreInstallsDir.startsWith("~/")) {
     jreInstallsDir = System.getProperty("user.home") + jreInstallsDir.substring(1)
   }
-  macosJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-mac-x64/jre")
-  windowsJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-windows-x64/jre")
-  linuxJavaVMDir = string("${jreInstallsDir}/jre-${JAVA_INTEGER_VERSION}-linux-x64/jre")
-  macosJavaVMTgz = string("${jreInstallsDir}/tgz/jre_${JAVA_INTEGER_VERSION}_mac_x64.tar.gz")
-  windowsJavaVMTgz = string("${jreInstallsDir}/tgz/jre_${JAVA_INTEGER_VERSION}_windows_x64.tar.gz")
-  linuxJavaVMTgz = string("${jreInstallsDir}/tgz/jre_${JAVA_INTEGER_VERSION}_linux_x64.tar.gz")
   install4jDir = string("${jalviewDir}/${install4j_utils_dir}")
   install4jConfFileName = string("jalview-install4j-conf.install4j")
   install4jConfFile = file("${install4jDir}/${install4jConfFileName}")
@@ -515,6 +544,14 @@ ext {
   helpSourceDir = string("${helpParentDir}/${help_dir}")
   helpFile = string("${helpBuildDir}/${help_dir}/help.jhm")
 
+  convertBinary = null
+  convertBinaryExpectedLocation = imagemagick_convert
+  if (convertBinaryExpectedLocation.startsWith("~/")) {
+    convertBinaryExpectedLocation = System.getProperty("user.home") + convertBinaryExpectedLocation.substring(1)
+  }
+  if (file(convertBinaryExpectedLocation).exists()) {
+    convertBinary = convertBinaryExpectedLocation
+  }
 
   relativeBuildDir = file(jalviewDirAbsolutePath).toPath().relativize(buildDir.toPath())
   jalviewjsBuildDir = string("${relativeBuildDir}/jalviewjs")
@@ -535,11 +572,16 @@ ext {
   jalviewjsJ2sAltSettingsFileName = string("${jalviewDir}/${jalviewjs_j2s_alt_settings}")
   jalviewjsJ2sProps = null
   jalviewjsJ2sPlugin = jalviewjs_j2s_plugin
+  jalviewjsStderrLaunchFilename = "${jalviewjsSiteDir}/"+(file(jalviewjs_stderr_launch).getName())
 
   eclipseWorkspace = null
   eclipseBinary = string("")
   eclipseVersion = string("")
   eclipseDebug = false
+
+  jalviewjsChromiumUserDir = "${jalviewjsBuildDir}/${jalviewjs_chromium_user_dir}"
+  jalviewjsChromiumProfileDir = "${ext.jalviewjsChromiumUserDir}/${jalviewjs_chromium_profile_name}"
+
   // ENDEXT
 }
 
@@ -548,14 +590,14 @@ sourceSets {
   main {
     java {
       srcDirs sourceDir
-      destinationDirectory = file(classesDir)
+      outputDir = file(classesDir)
     }
 
     resources {
       srcDirs = [ resourcesBuildDir, docBuildDir, helpBuildDir ]
     }
 
-    compileClasspath = files(sourceSets.main.java.destinationDirectory)
+    compileClasspath = files(sourceSets.main.java.outputDir)
     compileClasspath += fileTree(dir: "${jalviewDir}/${libDir}", include: ["*.jar"])
 
     runtimeClasspath = compileClasspath
@@ -565,14 +607,14 @@ sourceSets {
   clover {
     java {
       srcDirs cloverInstrDir
-      destinationDirectory = cloverClassesDir
+      outputDir = cloverClassesDir
     }
 
     resources {
       srcDirs = sourceSets.main.resources.srcDirs
     }
 
-    compileClasspath = files( sourceSets.clover.java.destinationDirectory )
+    compileClasspath = files( sourceSets.clover.java.outputDir )
     //compileClasspath += files( testClassesDir )
     compileClasspath += fileTree(dir: "${jalviewDir}/${libDir}", include: ["*.jar"])
     compileClasspath += fileTree(dir: "${jalviewDir}/${clover_lib_dir}", include: ["*.jar"])
@@ -584,14 +626,14 @@ sourceSets {
   test {
     java {
       srcDirs testSourceDir
-      destinationDirectory = file(testClassesDir)
+      outputDir = file(testClassesDir)
     }
 
     resources {
       srcDirs = useClover ? sourceSets.clover.resources.srcDirs : sourceSets.main.resources.srcDirs
     }
 
-    compileClasspath = files( sourceSets.test.java.destinationDirectory )
+    compileClasspath = files( sourceSets.test.java.outputDir )
     compileClasspath += useClover ? sourceSets.clover.compileClasspath : sourceSets.main.compileClasspath
     compileClasspath += fileTree(dir: "${jalviewDir}/${utils_dir}/testnglibs", include: ["**/*.jar"])
 
@@ -616,7 +658,7 @@ eclipse {
   }
 
   classpath {
-    //defaultOutputDir = sourceSets.main.java.destinationDirectory
+    //defaultOutputDir = sourceSets.main.java.outputDir
     configurations.each{ c->
       if (c.isCanBeResolved()) {
         minusConfigurations += [c]
@@ -655,7 +697,7 @@ eclipse {
         HashMap<String, Boolean> alreadyAddedLibPath = new HashMap<>();
 
         sourceSets.main.compileClasspath.findAll { it.name.endsWith(".jar") }.any {
-          //don't want to add destinationDirectory as eclipse is using its own output dir in bin/main
+          //don't want to add outputDir as eclipse is using its own output dir in bin/main
           if (it.isDirectory() || ! it.exists()) {
             // don't add dirs to classpath, especially if they don't exist
             return false // groovy "continue" in .any closure
@@ -675,7 +717,7 @@ eclipse {
         }
 
         sourceSets.test.compileClasspath.findAll { it.name.endsWith(".jar") }.any {
-          //no longer want to add destinationDirectory as eclipse is using its own output dir in bin/main
+          //no longer want to add outputDir as eclipse is using its own output dir in bin/main
           if (it.isDirectory() || ! it.exists()) {
             // don't add dirs to classpath
             return false // groovy "continue" in .any closure
@@ -1031,7 +1073,7 @@ compileJava {
   // JBP->BS should the print statement in doFirst refer to compile_target_compatibility ?
   sourceCompatibility = compile_source_compatibility
   targetCompatibility = compile_target_compatibility
-  options.compilerArgs = additional_compiler_args
+  options.compilerArgs += additional_compiler_args
   options.encoding = "UTF-8"
   doFirst {
     print ("Setting target compatibility to "+compile_target_compatibility+"\n")
@@ -1043,7 +1085,7 @@ compileJava {
 compileTestJava {
   sourceCompatibility = compile_source_compatibility
   targetCompatibility = compile_target_compatibility
-  options.compilerArgs = additional_compiler_args
+  options.compilerArgs += additional_compiler_args
   doFirst {
     print ("Setting target compatibility to "+targetCompatibility+"\n")
   }
@@ -1052,7 +1094,7 @@ compileTestJava {
 
 clean {
   doFirst {
-    delete sourceSets.main.java.destinationDirectory
+    delete sourceSets.main.java.outputDir
   }
 }
 
@@ -1060,7 +1102,7 @@ clean {
 cleanTest {
   dependsOn cleanClover
   doFirst {
-    delete sourceSets.test.java.destinationDirectory
+    delete sourceSets.test.java.outputDir
   }
 }
 
@@ -1152,7 +1194,7 @@ def convertMdToHtml (FileTree mdFiles, File cssFile) {
 
 task copyDocs(type: Copy) {
   def inputDir = "${jalviewDir}/${doc_dir}"
-  def destinationDirectory = "${docBuildDir}/${doc_dir}"
+  def outputDir = "${docBuildDir}/${doc_dir}"
   from(inputDir) {
     include('**/*.txt')
     include('**/*.md')
@@ -1173,10 +1215,10 @@ task copyDocs(type: Copy) {
     exclude('**/*.html')
     exclude('**/*.xml')
   }
-  into destinationDirectory
+  into outputDir
 
   inputs.dir(inputDir)
-  outputs.dir(destinationDirectory)
+  outputs.dir(outputDir)
 }
 
 
@@ -1241,15 +1283,15 @@ def mdFileComponents(File mdFile, def dateOnly=false) {
       }
       if (inFrontMatter) {
         def m = null
-        if (m == line =~ /^date:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) {
+        if (m = line =~ /^date:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/) {
           map["date"] = new Date().parse("yyyy-MM-dd HH:mm:ss", m[0][1])
-        } else if (m == line =~ /^date:\s*(\d{4}-\d{2}-\d{2})/) {
+        } else if (m = line =~ /^date:\s*(\d{4}-\d{2}-\d{2})/) {
           map["date"] = new Date().parse("yyyy-MM-dd", m[0][1])
-        } else if (m == line =~ /^channel:\s*(\S+)/) {
+        } else if (m = line =~ /^channel:\s*(\S+)/) {
           map["channel"] = m[0][1]
-        } else if (m == line =~ /^version:\s*(\S+)/) {
+        } else if (m = line =~ /^version:\s*(\S+)/) {
           map["version"] = m[0][1]
-        } else if (m == line =~ /^\s*([^:]+)\s*:\s*(\S.*)/) {
+        } else if (m = line =~ /^\s*([^:]+)\s*:\s*(\S.*)/) {
           map[ m[0][1] ] = m[0][2]
         }
         if (dateOnly && map["date"] != null) {
@@ -1308,7 +1350,7 @@ task hugoTemplates {
       def inSection = false
       changes.eachLine { line ->
         def m = null
-        if (m == line =~ /^##([^#].*)$/) {
+        if (m = line =~ /^##([^#].*)$/) {
           if (inSection) {
             changesHugo += "</div>\n\n"
           }
@@ -1318,7 +1360,7 @@ task hugoTemplates {
           section = section.replaceAll(/[^a-z0-9_\-]/, "")
           changesHugo += "<div class=\"${section}\">\n\n"
           inSection = true
-        } else if (m == line =~ /^(\s*-\s*)<!--([^>]+)-->(.*?)(<br\/?>)?\s*$/) {
+        } else if (m = line =~ /^(\s*-\s*)<!--([^>]+)-->(.*?)(<br\/?>)?\s*$/) {
           def comment = m[0][2].trim()
           if (comment != "") {
             comment = comment.replaceAll('"', "&quot;")
@@ -1388,7 +1430,7 @@ def getMdSections(String content) {
   def sectionName = null
   content.eachLine { line ->
     def m = null
-    if (m == line =~ /^##([^#].*)$/) {
+    if (m = line =~ /^##([^#].*)$/) {
       if (sectionName != null) {
         sections[sectionName] = sectionContent
         sectionName = null
@@ -1411,7 +1453,7 @@ def getMdSections(String content) {
 
 task copyHelp(type: Copy) {
   def inputDir = helpSourceDir
-  def destinationDirectory = "${helpBuildDir}/${help_dir}"
+  def outputDir = "${helpBuildDir}/${help_dir}"
   from(inputDir) {
     include('**/*.txt')
     include('**/*.md')
@@ -1436,15 +1478,14 @@ task copyHelp(type: Copy) {
     exclude('**/*.xml')
     exclude('**/*.jhm')
   }
-  into destinationDirectory
+  into outputDir
 
   inputs.dir(inputDir)
   outputs.files(helpFile)
-  outputs.dir(destinationDirectory)
+  outputs.dir(outputDir)
 }
 
 
-/*
 task releasesTemplates {
   group "help"
   description "Recreate whatsNew.html and releases.html from markdown files and templates in help"
@@ -1521,9 +1562,9 @@ task releasesTemplates {
       def lm = null
       def rContentProcessed = ""
       rContent.eachLine { line ->
-        if (lm == line =~ /^(\s*-)(\s*<!--[^>]*?-->)(.*)$/) {
+        if (lm = line =~ /^(\s*-)(\s*<!--[^>]*?-->)(.*)$/) {
           line = "${lm[0][1]}${lm[0][3]}${lm[0][2]}"
-      } else if (lm == line =~ /^###([^#]+.*)$/) {
+      } else if (lm = line =~ /^###([^#]+.*)$/) {
           line = "_${lm[0][1].trim()}_"
         }
         rContentProcessed += line + "\n"
@@ -1581,14 +1622,13 @@ task releasesTemplates {
   outputs.file(whatsnewHtmlFile)
 }
 
-*/
 
 task copyResources(type: Copy) {
   group = "build"
   description = "Copy (and make text substitutions in) the resources dir to the build area"
 
   def inputDir = resourceDir
-  def destinationDirectory = resourcesBuildDir
+  def outputDir = resourcesBuildDir
   from(inputDir) {
     include('**/*.txt')
     include('**/*.md')
@@ -1609,10 +1649,10 @@ task copyResources(type: Copy) {
     exclude('**/*.html')
     exclude('**/*.xml')
   }
-  into destinationDirectory
+  into outputDir
 
   inputs.dir(inputDir)
-  outputs.dir(destinationDirectory)
+  outputs.dir(outputDir)
 }
 
 task copyChannelResources(type: Copy) {
@@ -1621,17 +1661,28 @@ task copyChannelResources(type: Copy) {
   description = "Copy the channel resources dir to the build resources area"
 
   def inputDir = "${channelDir}/${resource_dir}"
-  def destinationDirectory = resourcesBuildDir
-  from inputDir
-  into destinationDirectory
+  def outputDir = resourcesBuildDir
+  from(inputDir) {
+    include(channel_props)
+    filter(ReplaceTokens,
+      beginToken: '__',
+      endToken: '__',
+      tokens: [
+        'SUFFIX': channelSuffix
+      ]
+    )
+  }
+  from(inputDir) {
+    exclude(channel_props)
+  }
+  into outputDir
 
   inputs.dir(inputDir)
-  outputs.dir(destinationDirectory)
+  outputs.dir(outputDir)
 }
 
 task createBuildProperties(type: WriteProperties) {
   dependsOn copyResources
-  dependsOn copyChannelResources
   group = "build"
   description = "Create the ${buildProperties} file"
   
@@ -1655,7 +1706,6 @@ task createBuildProperties(type: WriteProperties) {
 
 task buildIndices(type: JavaExec) {
   dependsOn copyHelp
-  //dependsOn releasesTemplates
   classpath = sourceSets.main.compileClasspath
   main = "com.sun.java.help.search.Indexer"
   workingDir = "${helpBuildDir}/${help_dir}"
@@ -1683,44 +1733,153 @@ task prepare {
   dependsOn buildResources
   dependsOn copyDocs
   dependsOn copyHelp
-  //dependsOn releasesTemplates
+  dependsOn releasesTemplates
   dependsOn convertMdFiles
   dependsOn buildIndices
 }
 
 
-// random block of dependencies
 compileJava.dependsOn prepare
 run.dependsOn compileJava
-//run.dependsOn prepare
-compileTestJava.dependsOn compileJava //
-compileTestJava.dependsOn buildIndices //
-processResources.dependsOn copyChannelResources //
-processResources.dependsOn copyResources //
-processResources.dependsOn createBuildProperties //
-processResources.dependsOn copyDocs //
-processResources.dependsOn convertMdFiles //
-processResources.dependsOn copyHelp //
-processResources.dependsOn buildIndices //
-
-
-//testReportDirName = "test-reports" // note that test workingDir will be $jalviewDir
+compileTestJava.dependsOn compileJava
+
+
+
 test {
-  dependsOn prepare
+  group = "Verification"
+  description = "Runs all testTaskN tasks)"
 
   if (useClover) {
     dependsOn cloverClasses
-   } else { //?
-    dependsOn compileJava //?
+  } else { //?
+    dependsOn testClasses
   }
 
+  // not running tests in this task
+  exclude "**/*"
+}
+/* testTask0 is the main test task */
+task testTask0(type: Test) {
+  group = "Verification"
+  description = "The main test task. Runs all non-testTaskN-labelled tests (unless excluded)"
   useTestNG() {
-    includeGroups testng_groups
-    excludeGroups testng_excluded_groups
+    includeGroups testng_groups.split(",")
+    excludeGroups testng_excluded_groups.split(",")
+    tasks.withType(Test).matching {it.name.startsWith("testTask") && it.name != name}.all {t -> excludeGroups t.name}
     preserveOrder true
     useDefaultListeners=true
   }
+  timeout = Duration.ofMinutes(15)
+}
 
+/* separated tests */
+task testTask1(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+  timeout = Duration.ofMinutes(5)
+}
+
+task testTask2(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+  timeout = Duration.ofMinutes(5)
+}
+task testTask3(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+  timeout = Duration.ofMinutes(5)
+}
+
+/* insert more testTaskNs here -- change N to next digit or other string */
+/*
+task testTaskN(type: Test) {
+  group = "Verification"
+  description = "Tests that need to be isolated from the main test run"
+  useTestNG() {
+    includeGroups name
+    excludeGroups testng_excluded_groups.split(",")
+    preserveOrder true
+    useDefaultListeners=true
+  }
+}
+*/
+
+
+/*
+ * adapted from https://medium.com/@wasyl/pretty-tests-summary-in-gradle-744804dd676c
+ * to summarise test results from all Test tasks
+ */
+/* START of test tasks results summary */
+import groovy.time.TimeCategory
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import org.gradle.api.tasks.testing.logging.TestLogEvent
+rootProject.ext.testsResults = [] // Container for tests summaries
+
+tasks.withType(Test).matching {t -> t.getName().startsWith("testTask")}.all { testTask ->
+
+  // from original test task
+  if (useClover) {
+    dependsOn cloverClasses
+  } else { //?
+    dependsOn testClasses //?
+  }
+
+  // run main tests first
+  if (!testTask.name.equals("testTask0"))
+    testTask.mustRunAfter "testTask0"
+
+  testTask.testLogging { logging ->
+    events TestLogEvent.FAILED
+//      TestLogEvent.SKIPPED,
+//      TestLogEvent.STANDARD_OUT,
+//      TestLogEvent.STANDARD_ERROR
+
+    exceptionFormat TestExceptionFormat.FULL
+    showExceptions true
+    showCauses true
+    showStackTraces true
+    if (test_output) {
+      showStandardStreams true
+    }
+    info.events = [ TestLogEvent.FAILED ]
+  }
+
+  if (OperatingSystem.current().isMacOsX()) {
+    testTask.systemProperty "apple.awt.UIElement", "true"
+    testTask.environment "JAVA_TOOL_OPTIONS", "-Dapple.awt.UIElement=true"
+  }
+
+
+  ignoreFailures = true // Always try to run all tests for all modules
+
+  afterSuite { desc, result ->
+    if (desc.parent)
+      return // Only summarize results for whole modules
+
+    def resultsInfo = [testTask.project.name, testTask.name, result, TimeCategory.minus(new Date(result.endTime), new Date(result.startTime)), testTask.reports.html.entryPoint]
+
+    rootProject.ext.testsResults.add(resultsInfo)
+  }
+
+  // from original test task
   maxHeapSize = "1024m"
 
   workingDir = jalviewDir
@@ -1739,14 +1898,145 @@ test {
   jvmArgs += additional_compiler_args
 
   doFirst {
+    // this is not perfect yet -- we should only add the commandLineIncludePatterns to the
+    // testTasks that include the tests, and exclude all from the others.
+    // get --test argument
+    filter.commandLineIncludePatterns = test.filter.commandLineIncludePatterns
+    // do something with testTask.getCandidateClassFiles() to see if the test should silently finish because of the
+    // commandLineIncludePatterns not matching anything.  Instead we are doing setFailOnNoMatchingTests(false) below
+
+
     if (useClover) {
       println("Running tests " + (useClover?"WITH":"WITHOUT") + " clover")
     }
   }
+
+
+  /* don't fail on no matching tests (so --tests will run across all testTasks) */
+  testTask.filter.setFailOnNoMatchingTests(false)
+
+  /* ensure the "test" task dependsOn all the testTasks */
+  test.dependsOn testTask
 }
 
+gradle.buildFinished {
+    def allResults = rootProject.ext.testsResults
+
+    if (!allResults.isEmpty()) {
+        printResults allResults
+        allResults.each {r ->
+          if (r[2].resultType == TestResult.ResultType.FAILURE)
+            throw new GradleException("Failed tests!")
+        }
+    }
+}
+
+private static String colString(styler, col, colour, text) {
+  return col?"${styler[colour](text)}":text
+}
+
+private static String getSummaryLine(s, pn, tn, rt, rc, rs, rf, rsk, t, col) {
+  def colour = 'black'
+  def text = rt
+  def nocol = false
+  if (rc == 0) {
+    text = "-----"
+    nocol = true
+  } else {
+    switch(rt) {
+      case TestResult.ResultType.SUCCESS:
+        colour = 'green'
+        break;
+      case TestResult.ResultType.FAILURE:
+        colour = 'red'
+        break;
+      default:
+        nocol = true
+        break;
+    }
+  }
+  StringBuilder sb = new StringBuilder()
+  sb.append("${pn}")
+  if (tn != null)
+    sb.append(":${tn}")
+  sb.append(" results: ")
+  sb.append(colString(s, col && !nocol, colour, text))
+  sb.append(" (")
+  sb.append("${rc} tests, ")
+  sb.append(colString(s, col && rs > 0, 'green', rs))
+  sb.append(" successes, ")
+  sb.append(colString(s, col && rf > 0, 'red', rf))
+  sb.append(" failures, ")
+  sb.append("${rsk} skipped) in ${t}")
+  return sb.toString()
+}
+
+private static void printResults(allResults) {
+
+    // styler from https://stackoverflow.com/a/56139852
+    def styler = 'black red green yellow blue magenta cyan white'.split().toList().withIndex(30).collectEntries { key, val -> [(key) : { "\033[${val}m${it}\033[0m" }] }
+
+    def maxLength = 0
+    def failedTests = false
+    def summaryLines = []
+    def totalcount = 0
+    def totalsuccess = 0
+    def totalfail = 0
+    def totalskip = 0
+    def totaltime = TimeCategory.getSeconds(0)
+    // sort on project name then task name
+    allResults.sort {a, b -> a[0] == b[0]? a[1]<=>b[1]:a[0] <=> b[0]}.each {
+      def projectName = it[0]
+      def taskName = it[1]
+      def result = it[2]
+      def time = it[3]
+      def report = it[4]
+      def summaryCol = getSummaryLine(styler, projectName, taskName, result.resultType, result.testCount, result.successfulTestCount, result.failedTestCount, result.skippedTestCount, time, true)
+      def summaryPlain = getSummaryLine(styler, projectName, taskName, result.resultType, result.testCount, result.successfulTestCount, result.failedTestCount, result.skippedTestCount, time, false)
+      def reportLine = "Report file: ${report}"
+      def ls = summaryPlain.length()
+      def lr = reportLine.length()
+      def m = [ls, lr].max()
+      if (m > maxLength)
+        maxLength = m
+      def info = [ls, summaryCol, reportLine]
+      summaryLines.add(info)
+      failedTests |= result.resultType == TestResult.ResultType.FAILURE
+      totalcount += result.testCount
+      totalsuccess += result.successfulTestCount
+      totalfail += result.failedTestCount
+      totalskip += result.skippedTestCount
+      totaltime += time
+    }
+    def totalSummaryCol = getSummaryLine(styler, "OVERALL", "", failedTests?TestResult.ResultType.FAILURE:TestResult.ResultType.SUCCESS, totalcount, totalsuccess, totalfail, totalskip, totaltime, true)
+    def totalSummaryPlain = getSummaryLine(styler, "OVERALL", "", failedTests?TestResult.ResultType.FAILURE:TestResult.ResultType.SUCCESS, totalcount, totalsuccess, totalfail, totalskip, totaltime, false)
+    def tls = totalSummaryPlain.length()
+    if (tls > maxLength)
+      maxLength = tls
+    def info = [tls, totalSummaryCol, null]
+    summaryLines.add(info)
+
+    def allSummaries = []
+    for(sInfo : summaryLines) {
+      def ls = sInfo[0]
+      def summary = sInfo[1]
+      def report = sInfo[2]
+
+      StringBuilder sb = new StringBuilder()
+      sb.append("│" + summary + " " * (maxLength - ls) + "│")
+      if (report != null) {
+        sb.append("\n│" + report + " " * (maxLength - report.length()) + "│")
+      }
+      allSummaries += sb.toString()
+    }
+
+    println "┌${"${"─" * maxLength}"}┐"
+    println allSummaries.join("\n├${"${"─" * maxLength}"}┤\n")
+    println "└${"${"─" * maxLength}"}┘"
+}
+/* END of test tasks results summary */
+
 
-/*
 task compileLinkCheck(type: JavaCompile) {
   options.fork = true
   classpath = files("${jalviewDir}/${utils_dir}")
@@ -1781,7 +2071,6 @@ task linkCheck(type: JavaExec) {
   inputs.dir(helpBuildDir)
   outputs.file(helpLinksCheckerOutFile)
 }
-*/
 
 
 // import the pubhtmlhelp target
@@ -1796,14 +2085,10 @@ task cleanPackageDir(type: Delete) {
   }
 }
 
-// block of dependencies
-//compileTestJava.dependsOn compileLinkCheck //
-//copyChannelResources.dependsOn compileLinkCheck //
-//convertMdFiles.dependsOn compileLinkCheck //
 
 jar {
   dependsOn prepare
-  dependsOn //linkCheck
+  dependsOn linkCheck
 
   manifest {
     attributes "Main-Class": main_class,
@@ -1813,8 +2098,8 @@ jar {
     "Implementation-Version": JALVIEW_VERSION
   }
 
-  def destinationDirectory = "${jalviewDir}/${package_dir}"
-  destinationDirectory = file(destinationDirectory)
+  def outputDir = "${jalviewDir}/${package_dir}"
+  destinationDirectory = file(outputDir)
   archiveFileName = rootProject.name+".jar"
   duplicatesStrategy "EXCLUDE"
 
@@ -1825,11 +2110,11 @@ jar {
   exclude "**/*.jar"
   exclude "**/*.jar.*"
 
-  inputs.dir(sourceSets.main.java.destinationDirectory)
+  inputs.dir(sourceSets.main.java.outputDir)
   sourceSets.main.resources.srcDirs.each{ dir ->
     inputs.dir(dir)
   }
-  outputs.file("${destinationDirectory}/${archiveFileName}")
+  outputs.file("${outputDir}/${archiveFileName}")
 }
 
 
@@ -1870,32 +2155,114 @@ task cleanDist {
 }
 
 
+task launcherJar(type: Jar) {
+  manifest {
+      attributes (
+        "Main-Class": shadow_jar_main_class,
+        "Implementation-Version": JALVIEW_VERSION,
+        "Application-Name": applicationName
+      )
+  }
+}
+
 shadowJar {
   group = "distribution"
   description = "Create a single jar file with all dependency libraries merged. Can be run with java -jar"
   if (buildDist) {
     dependsOn makeDist
   }
-  from ("${jalviewDir}/${libDistDir}") {
-    include("*.jar")
-  }
+
+  def jarFiles = fileTree(dir: "${jalviewDir}/${libDistDir}", include: "*.jar", exclude: "regex.jar").getFiles()
+  def groovyJars = jarFiles.findAll {it1 -> file(it1).getName().startsWith("groovy-swing")}
+  def otherJars = jarFiles.findAll {it2 -> !file(it2).getName().startsWith("groovy-swing")}
+  from groovyJars
+  from otherJars
+
   manifest {
-    attributes "Implementation-Version": JALVIEW_VERSION,
-    "Application-Name": applicationName
+    // shadowJar manifest must inheritFrom another Jar task.  Can't set attributes here.
+    inheritFrom(project.tasks.launcherJar.manifest)
+  }
+  // we need to include the groovy-swing Include-Package for it to run in the shadowJar
+  doFirst {
+    def jarFileManifests = []
+    groovyJars.each { jarFile ->
+      def mf = zipTree(jarFile).getFiles().find { it.getName().equals("MANIFEST.MF") }
+      if (mf != null) {
+        jarFileManifests += mf
+      }
+    }
+    manifest {
+      from (jarFileManifests) {
+        eachEntry { details ->
+          if (!details.key.equals("Import-Package")) {
+            details.exclude()
+          }
+        }
+      }
+    }
   }
 
   duplicatesStrategy "INCLUDE"
 
+  // this mainClassName is mandatory but gets ignored due to manifest created in doFirst{}. Set the Main-Class as an attribute in launcherJar instead
   mainClassName = shadow_jar_main_class
   mergeServiceFiles()
-  archiveClassifier = "all-"+JALVIEW_VERSION+"-j"+JAVA_VERSION
+  classifier = "all-"+JALVIEW_VERSION+"-j"+JAVA_VERSION
   minimize()
 }
 
+task getdownImagesCopy() {
+  inputs.dir getdownImagesDir
+  outputs.dir getdownImagesBuildDir
 
-task getdownWebsite() {
+  doFirst {
+    copy {
+      from(getdownImagesDir) {
+        include("*getdown*.png")
+      }
+      into getdownImagesBuildDir
+    }
+  }
+}
+
+task getdownImagesProcess() {
+  dependsOn getdownImagesCopy
+
+  doFirst {
+    if (backgroundImageText) {
+      if (convertBinary == null) {
+        throw new StopExecutionException("No ImageMagick convert binary installed at '${convertBinaryExpectedLocation}'")
+      }
+      if (!project.hasProperty("getdown_background_image_text_suffix_cmd")) {
+        throw new StopExecutionException("No property 'getdown_background_image_text_suffix_cmd' defined. See channel_gradle.properties for channel ${CHANNEL}")
+      }
+      fileTree(dir: getdownImagesBuildDir, include: "*background*.png").getFiles().each { file ->
+        exec {
+          executable convertBinary
+          args = [
+            file.getPath(),
+            '-font', getdown_background_image_text_font,
+            '-fill', getdown_background_image_text_colour,
+            '-draw', sprintf(getdown_background_image_text_suffix_cmd, channelSuffix),
+            '-draw', sprintf(getdown_background_image_text_commit_cmd, "git-commit: ${gitHash}"),
+            '-draw', sprintf(getdown_background_image_text_date_cmd, getDate("yyyy-MM-dd HH:mm:ss")),
+            file.getPath()
+          ]
+        }
+      }
+    }
+  }
+}
+
+task getdownImages() {
+  dependsOn getdownImagesProcess
+}
+
+task getdownWebsiteBuild() {
   group = "distribution"
-  description = "Create the getdown minimal app folder, and website folder for this version of jalview. Website folder also used for offline app installer"
+  description = "Create the getdown minimal app folder, and website folder for this version of jalview. Website folder also used for offline app installer. No digest is created."
+
+  dependsOn getdownImages
   if (buildDist) {
     dependsOn makeDist
   }
@@ -1918,6 +2285,13 @@ task getdownWebsite() {
 
     copy {
       from channelPropsFile
+      filter(ReplaceTokens,
+        beginToken: '__',
+        endToken: '__',
+        tokens: [
+          'SUFFIX': channelSuffix
+        ]
+      )
       into getdownAppBaseDir
     }
     getdownWebsiteResourceFilenames += file(channelPropsFile).getName()
@@ -1933,11 +2307,11 @@ task getdownWebsite() {
     if (getdownAltMultiJavaLocation != null && getdownAltMultiJavaLocation.length() > 0) {
       props.put("getdown_txt_multi_java_location", getdownAltMultiJavaLocation)
     }
-    if (getdownImagesDir != null && file(getdownImagesDir).exists()) {
-      props.put("getdown_txt_ui.background_image", "${getdownImagesDir}/${getdown_background_image}")
-      props.put("getdown_txt_ui.instant_background_image", "${getdownImagesDir}/${getdown_instant_background_image}")
-      props.put("getdown_txt_ui.error_background", "${getdownImagesDir}/${getdown_error_background}")
-      props.put("getdown_txt_ui.progress_image", "${getdownImagesDir}/${getdown_progress_image}")
+    if (getdownImagesBuildDir != null && file(getdownImagesBuildDir).exists()) {
+      props.put("getdown_txt_ui.background_image", "${getdownImagesBuildDir}/${getdown_background_image}")
+      props.put("getdown_txt_ui.instant_background_image", "${getdownImagesBuildDir}/${getdown_instant_background_image}")
+      props.put("getdown_txt_ui.error_background", "${getdownImagesBuildDir}/${getdown_error_background}")
+      props.put("getdown_txt_ui.progress_image", "${getdownImagesBuildDir}/${getdown_progress_image}")
       props.put("getdown_txt_ui.icon", "${getdownImagesDir}/${getdown_icon}")
       props.put("getdown_txt_ui.mac_dock_icon", "${getdownImagesDir}/${getdown_mac_dock_icon}")
     }
@@ -1998,7 +2372,7 @@ task getdownWebsite() {
           from s
           into "${getdownAppBaseDir}/${getdown_wrapper_script_dir}"
         }
-        getdownTextLines += "resource = ${getdown_wrapper_script_dir}/${script}"
+        getdownTextLines += "xresource = ${getdown_wrapper_script_dir}/${script}"
       }
     }
 
@@ -2140,7 +2514,9 @@ task getdownDigestDir(type: JavaExec) {
 task getdownDigest(type: JavaExec) {
   group = "distribution"
   description = "Digest the getdown website folder"
-  dependsOn getdownWebsite
+
+  dependsOn getdownWebsiteBuild
+
   doFirst {
     classpath = files(getdownLauncher)
   }
@@ -2173,12 +2549,19 @@ task getdown() {
   }
 }
 
+task getdownWebsite {
+  group = "distribution"
+  description = "A task to create the whole getdown channel website dir including digest file"
+
+  dependsOn getdownWebsiteBuild
+  dependsOn getdownDigest
+}
 
 task getdownArchiveBuild() {
   group = "distribution"
   description = "Put files in the archive dir to go on the website"
 
-  dependsOn getdownWebsite
+  dependsOn getdownWebsiteBuild
 
   def v = "v${JALVIEW_VERSION_UNDERSCORES}"
   def vDir = "${getdownArchiveDir}/${v}"
@@ -2395,12 +2778,59 @@ task cleanInstallersDataFiles {
   }
 }
 
+task install4jDMGBackgroundImageCopy {
+  inputs.file "${install4jDMGBackgroundImageDir}/${install4jDMGBackgroundImageFile}"
+  outputs.dir "${install4jDMGBackgroundImageBuildDir}"
+  doFirst {
+    copy {
+      from(install4jDMGBackgroundImageDir) {
+        include(install4jDMGBackgroundImageFile)
+      }
+      into install4jDMGBackgroundImageBuildDir
+    }
+  }
+}
+
+task install4jDMGBackgroundImageProcess {
+  dependsOn install4jDMGBackgroundImageCopy
+
+  doFirst {
+    if (backgroundImageText) {
+      if (convertBinary == null) {
+        throw new StopExecutionException("No ImageMagick convert binary installed at '${convertBinaryExpectedLocation}'")
+      }
+      if (!project.hasProperty("install4j_background_image_text_suffix_cmd")) {
+        throw new StopExecutionException("No property 'install4j_background_image_text_suffix_cmd' defined. See channel_gradle.properties for channel ${CHANNEL}")
+      }
+      fileTree(dir: install4jDMGBackgroundImageBuildDir, include: "*.png").getFiles().each { file ->
+        exec {
+          executable convertBinary
+          args = [
+            file.getPath(),
+            '-font', install4j_background_image_text_font,
+            '-fill', install4j_background_image_text_colour,
+            '-draw', sprintf(install4j_background_image_text_suffix_cmd, channelSuffix),
+            '-draw', sprintf(install4j_background_image_text_commit_cmd, "git-commit: ${gitHash}"),
+            '-draw', sprintf(install4j_background_image_text_date_cmd, getDate("yyyy-MM-dd HH:mm:ss")),
+            file.getPath()
+          ]
+        }
+      }
+    }
+  }
+}
+
+task install4jDMGBackgroundImage {
+  dependsOn install4jDMGBackgroundImageProcess
+}
+
 task installerFiles(type: com.install4j.gradle.Install4jTask) {
   group = "distribution"
   description = "Create the install4j installers"
   dependsOn getdown
   dependsOn copyInstall4jTemplate
   dependsOn cleanInstallersDataFiles
+  dependsOn install4jDMGBackgroundImage
 
   projectFile = install4jConfFile
 
@@ -2432,21 +2862,16 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
     'JAVA_VERSION': JAVA_VERSION,
     'JAVA_INTEGER_VERSION': JAVA_INTEGER_VERSION,
     'VERSION': JALVIEW_VERSION,
-    'MACOS_JAVA_VM_DIR': macosJavaVMDir,
-    'WINDOWS_JAVA_VM_DIR': windowsJavaVMDir,
-    'LINUX_JAVA_VM_DIR': linuxJavaVMDir,
-    'MACOS_JAVA_VM_TGZ': macosJavaVMTgz,
-    'WINDOWS_JAVA_VM_TGZ': windowsJavaVMTgz,
-    'LINUX_JAVA_VM_TGZ': linuxJavaVMTgz,
     'COPYRIGHT_MESSAGE': install4j_copyright_message,
     'BUNDLE_ID': install4jBundleId,
     'INTERNAL_ID': install4jInternalId,
     'WINDOWS_APPLICATION_ID': install4jWinApplicationId,
     'MACOS_DMG_DS_STORE': install4jDMGDSStore,
-    'MACOS_DMG_BG_IMAGE': install4jDMGBackgroundImage,
+    'MACOS_DMG_BG_IMAGE': "${install4jDMGBackgroundImageBuildDir}/${install4jDMGBackgroundImageFile}",
     'WRAPPER_LINK': getdownWrapperLink,
     'BASH_WRAPPER_SCRIPT': getdown_bash_wrapper_script,
     'POWERSHELL_WRAPPER_SCRIPT': getdown_powershell_wrapper_script,
+    'BATCH_WRAPPER_SCRIPT': getdown_batch_wrapper_script,
     'WRAPPER_SCRIPT_BIN_DIR': getdown_wrapper_script_dir,
     'INSTALLER_NAME': install4jInstallerName,
     'INSTALL4J_UTILS_DIR': install4j_utils_dir,
@@ -2467,8 +2892,29 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
     'WINDOWS_ICONS_FILE': install4jWindowsIconsFile,
     'PNG_ICON_FILE': install4jPngIconFile,
     'BACKGROUND': install4jBackground,
+  ]
 
+  def varNameMap = [
+    'mac': 'MACOS',
+    'windows': 'WINDOWS',
+    'linux': 'LINUX'
   ]
+  
+  // these are the bundled OS/architecture VMs needed by install4j
+  def osArch = [
+    [ "mac", "x64" ],
+    [ "mac", "aarch64" ],
+    [ "windows", "x64" ],
+    [ "linux", "x64" ],
+    [ "linux", "aarch64" ]
+  ]
+  osArch.forEach { os, arch ->
+    variables[ sprintf("%s_%s_JAVA_VM_DIR", varNameMap[os], arch.toUpperCase(Locale.ROOT)) ] = sprintf("%s/jre-%s-%s-%s/jre", jreInstallsDir, JAVA_INTEGER_VERSION, os, arch)
+    // N.B. For some reason install4j requires the below filename to have underscores and not hyphens
+    // otherwise running `gradle installers` generates a non-useful error:
+    // `install4j: compilation failed. Reason: java.lang.NumberFormatException: For input string: "windows"`
+    variables[ sprintf("%s_%s_JAVA_VM_TGZ", varNameMap[os], arch.toUpperCase(Locale.ROOT)) ] = sprintf("%s/tgz/jre_%s_%s_%s.tar.gz", jreInstallsDir, JAVA_INTEGER_VERSION, os, arch)
+  }
 
   //println("INSTALL4J VARIABLES:")
   //variables.each{k,v->println("${k}=${v}")}
@@ -2502,8 +2948,6 @@ task installerFiles(type: com.install4j.gradle.Install4jTask) {
   inputs.dir(getdownAppBaseDir)
   inputs.file(install4jConfFile)
   inputs.file("${install4jDir}/${install4j_info_plist_file_associations}")
-  inputs.dir(macosJavaVMDir)
-  inputs.dir(windowsJavaVMDir)
   outputs.dir("${jalviewDir}/${install4j_build_dir}/${JAVA_VERSION}")
 }
 
@@ -2627,6 +3071,7 @@ task sourceDist(type: Tar) {
   into project.name
 
   def EXCLUDE_FILES=[
+    "dist/*",
     "build/*",
     "bin/*",
     "test-output/",
@@ -2943,10 +3388,10 @@ task jalviewjsEclipseCopyDropins(type: Copy) {
 
   def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjs_eclipse_dropins_dir}", include: "*.jar")
   inputFiles += file("${jalviewDir}/${jalviewjsJ2sPlugin}")
-  def destinationDirectory = "${jalviewDir}/${jalviewjsBuildDir}/${jalviewjs_eclipse_tmp_dropins_dir}"
+  def outputDir = "${jalviewDir}/${jalviewjsBuildDir}/${jalviewjs_eclipse_tmp_dropins_dir}"
 
   from inputFiles
-  into destinationDirectory
+  into outputDir
 }
 
 
@@ -3054,13 +3499,13 @@ task jalviewjsSyncAllLibs (type: Sync) {
   dependsOn jalviewjsTransferUnzipAllLibs
   def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjsTransferSiteLibDir}")
   inputFiles += fileTree(dir: "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}")
-  def destinationDirectory = "${jalviewDir}/${jalviewjsSiteDir}"
+  def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
 
   from inputFiles
-  into destinationDirectory
+  into outputDir
   def outputFiles = []
   rename { filename ->
-    outputFiles += "${destinationDirectory}/${filename}"
+    outputFiles += "${outputDir}/${filename}"
     null
   }
   preserve {
@@ -3079,13 +3524,13 @@ task jalviewjsSyncResources (type: Sync) {
   dependsOn buildResources
 
   def inputFiles = fileTree(dir: resourcesBuildDir)
-  def destinationDirectory = "${jalviewDir}/${jalviewjsSiteDir}/${jalviewjs_j2s_subdir}"
+  def outputDir = "${jalviewDir}/${jalviewjsSiteDir}/${jalviewjs_j2s_subdir}"
 
   from inputFiles
-  into destinationDirectory
+  into outputDir
   def outputFiles = []
   rename { filename ->
-    outputFiles += "${destinationDirectory}/${filename}"
+    outputFiles += "${outputDir}/${filename}"
     null
   }
   preserve {
@@ -3098,13 +3543,13 @@ task jalviewjsSyncResources (type: Sync) {
 
 task jalviewjsSyncSiteResources (type: Sync) {
   def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjs_site_resource_dir}")
-  def destinationDirectory = "${jalviewDir}/${jalviewjsSiteDir}"
+  def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
 
   from inputFiles
-  into destinationDirectory
+  into outputDir
   def outputFiles = []
   rename { filename ->
-    outputFiles += "${destinationDirectory}/${filename}"
+    outputFiles += "${outputDir}/${filename}"
     null
   }
   preserve {
@@ -3118,13 +3563,13 @@ task jalviewjsSyncSiteResources (type: Sync) {
 task jalviewjsSyncBuildProperties (type: Sync) {
   dependsOn createBuildProperties
   def inputFiles = [file(buildProperties)]
-  def destinationDirectory = "${jalviewDir}/${jalviewjsSiteDir}/${jalviewjs_j2s_subdir}"
+  def outputDir = "${jalviewDir}/${jalviewjsSiteDir}/${jalviewjs_j2s_subdir}"
 
   from inputFiles
-  into destinationDirectory
+  into outputDir
   def outputFiles = []
   rename { filename ->
-    outputFiles += "${destinationDirectory}/${filename}"
+    outputFiles += "${outputDir}/${filename}"
     null
   }
   preserve {
@@ -3343,7 +3788,7 @@ task jalviewjsBuildAllCores {
   def swingJ2sDir = "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}/${jalviewjs_j2s_subdir}"
   def libJ2sDir = "${jalviewDir}/${jalviewjsTransferSiteLibDir}/${jalviewjs_j2s_subdir}"
   def jsDir = "${jalviewDir}/${jalviewjsTransferSiteSwingJsDir}/${jalviewjs_js_subdir}"
-  def destinationDirectory = "${jalviewDir}/${jalviewjsTransferSiteCoreDir}/${jalviewjs_j2s_subdir}/core"
+  def outputDir = "${jalviewDir}/${jalviewjsTransferSiteCoreDir}/${jalviewjs_j2s_subdir}/core"
   def prefixFile = "${jsDir}/core/coretop2.js"
   def suffixFile = "${jsDir}/core/corebottom2.js"
 
@@ -3387,8 +3832,8 @@ task jalviewjsBuildAllCores {
     }
     def list = fileTree(dir: j2sDir, includes: filelist)
 
-    def jsfile = "${destinationDirectory}/core${name}.js"
-    def zjsfile = "${destinationDirectory}/core${name}.z.js"
+    def jsfile = "${outputDir}/core${name}.js"
+    def zjsfile = "${outputDir}/core${name}.z.js"
 
     jalviewjsCoreClasslists += [
       'jsfile': jsfile,
@@ -3406,8 +3851,8 @@ task jalviewjsBuildAllCores {
   // _stevesoft core. add any cores without a classlist here (and the inputs and outputs)
   def stevesoftClasslistName = "_stevesoft"
   def stevesoftClasslist = [
-    'jsfile': "${destinationDirectory}/core${stevesoftClasslistName}.js",
-    'zjsfile': "${destinationDirectory}/core${stevesoftClasslistName}.z.js",
+    'jsfile': "${outputDir}/core${stevesoftClasslistName}.js",
+    'zjsfile': "${outputDir}/core${stevesoftClasslistName}.z.js",
     'list': fileTree(dir: j2sDir, include: "com/stevesoft/pat/**/*.js"),
     'name': stevesoftClasslistName
   ]
@@ -3440,8 +3885,8 @@ task jalviewjsBuildAllCores {
     ]
   )
   def allClasslist = [
-    'jsfile': "${destinationDirectory}/core${allClasslistName}.js",
-    'zjsfile': "${destinationDirectory}/core${allClasslistName}.z.js",
+    'jsfile': "${outputDir}/core${allClasslistName}.js",
+    'zjsfile': "${outputDir}/core${allClasslistName}.z.js",
     'list': allJsFiles,
     'name': allClasslistName
   ]
@@ -3494,11 +3939,11 @@ task jalviewjsPublishCoreTemplates {
   dependsOn jalviewjsBuildAllCores
   def inputFileName = "${jalviewDir}/${j2s_coretemplate_html}"
   def inputFile = file(inputFileName)
-  def destinationDirectory = "${jalviewDir}/${jalviewjsTransferSiteCoreDir}"
+  def outputDir = "${jalviewDir}/${jalviewjsTransferSiteCoreDir}"
 
   def outputFiles = []
   jalviewjsCoreClasslists.each { cl ->
-    def outputFile = "${destinationDirectory}/${jalviewjsJalviewTemplateName}_${cl.name}.html"
+    def outputFile = "${outputDir}/${jalviewjsJalviewTemplateName}_${cl.name}.html"
     cl['outputfile'] = outputFile
     outputFiles += outputFile
   }
@@ -3517,13 +3962,13 @@ task jalviewjsSyncCore (type: Sync) {
   dependsOn jalviewjsBuildAllCores
   dependsOn jalviewjsPublishCoreTemplates
   def inputFiles = fileTree(dir: "${jalviewDir}/${jalviewjsTransferSiteCoreDir}")
-  def destinationDirectory = "${jalviewDir}/${jalviewjsSiteDir}"
+  def outputDir = "${jalviewDir}/${jalviewjsSiteDir}"
 
   from inputFiles
-  into destinationDirectory
+  into outputDir
   def outputFiles = []
   rename { filename ->
-    outputFiles += "${destinationDirectory}/${filename}"
+    outputFiles += "${outputDir}/${filename}"
     null
   }
   preserve {
@@ -3841,8 +4286,157 @@ task eclipseAutoBuildTask {
 }
 
 
+task jalviewjsCopyStderrLaunchFile(type: Copy) {
+  from file(jalviewjs_stderr_launch)
+  into jalviewjsSiteDir
+
+  inputs.file jalviewjs_stderr_launch
+  outputs.file jalviewjsStderrLaunchFilename
+}
+
+task cleanJalviewjsChromiumUserDir {
+  doFirst {
+    delete jalviewjsChromiumUserDir
+  }
+  outputs.dir jalviewjsChromiumUserDir
+  // always run when depended on
+  outputs.upToDateWhen { !file(jalviewjsChromiumUserDir).exists() }
+}
+
+task jalviewjsChromiumProfile {
+  dependsOn cleanJalviewjsChromiumUserDir
+  mustRunAfter cleanJalviewjsChromiumUserDir
+
+  def firstRun = file("${jalviewjsChromiumUserDir}/First Run")
+
+  doFirst {
+    mkdir jalviewjsChromiumProfileDir
+    firstRun.text = ""
+  }
+  outputs.file firstRun
+}
+
+task jalviewjsLaunchTest {
+  group "Test"
+  description "Check JalviewJS opens in a browser"
+  dependsOn jalviewjsBuildSite
+  dependsOn jalviewjsCopyStderrLaunchFile
+  dependsOn jalviewjsChromiumProfile
+
+  def macOS = OperatingSystem.current().isMacOsX()
+  def chromiumBinary = macOS ? jalviewjs_macos_chromium_binary : jalviewjs_chromium_binary
+  if (chromiumBinary.startsWith("~/")) {
+    chromiumBinary = System.getProperty("user.home") + chromiumBinary.substring(1)
+  }
+  
+  def stdout
+  def stderr
+  doFirst {
+    def timeoutms = Integer.valueOf(jalviewjs_chromium_overall_timeout) * 1000
+    
+    def binary = file(chromiumBinary)
+    if (!binary.exists()) {
+      throw new StopExecutionException("Could not find chromium binary '${chromiumBinary}'. Cannot run task ${name}.")
+    }
+    stdout = new ByteArrayOutputStream()
+    stderr = new ByteArrayOutputStream()
+    def execStdout
+    def execStderr
+    if (jalviewjs_j2s_to_console.equals("true")) {
+      execStdout = new org.apache.tools.ant.util.TeeOutputStream(
+        stdout,
+        System.out)
+      execStderr = new org.apache.tools.ant.util.TeeOutputStream(
+        stderr,
+        System.err)
+    } else {
+      execStdout = stdout
+      execStderr = stderr
+    }
+    def execArgs = [
+      "--no-sandbox", // --no-sandbox IS USED BY THE THORIUM APPIMAGE ON THE BUILDSERVER
+      "--headless=new",
+      "--disable-gpu",
+      "--timeout=${timeoutms}",
+      "--virtual-time-budget=${timeoutms}",
+      "--user-data-dir=${jalviewDirAbsolutePath}/${jalviewjsBuildDir}/${jalviewjs_chromium_user_dir}",
+      "--profile-directory=${jalviewjs_chromium_profile_name}",
+      "--allow-file-access-from-files",
+      "--enable-logging=stderr",
+      "file://${jalviewDirAbsolutePath}/${jalviewjsStderrLaunchFilename}"
+    ]
+    
+    if (true || macOS) {
+      ScheduledExecutorService executor = Executors.newScheduledThreadPool(3);
+      Future f1 = executor.submit(
+        () -> {
+          exec {
+            standardOutput = execStdout
+            errorOutput = execStderr
+            executable(chromiumBinary)
+            args(execArgs)
+            println "COMMAND: '"+commandLine.join(" ")+"'"
+          }
+          executor.shutdownNow()
+        }
+      )
+
+      def noChangeBytes = 0
+      def noChangeIterations = 0
+      executor.scheduleAtFixedRate(
+        () -> {
+          String stderrString = stderr.toString()
+          // shutdown the task if we have a success string
+          if (stderrString.contains(jalviewjs_desktop_init_string)) {
+            f1.cancel()
+            Thread.sleep(1000)
+            executor.shutdownNow()
+          }
+          // if no change in stderr for 10s then also end
+          if (noChangeIterations >= jalviewjs_chromium_idle_timeout) {
+            executor.shutdownNow()
+          }
+          if (stderrString.length() == noChangeBytes) {
+            noChangeIterations++
+          } else {
+            noChangeBytes = stderrString.length()
+            noChangeIterations = 0
+          }
+        },
+        1, 1, TimeUnit.SECONDS)
+
+      executor.schedule(new Runnable(){
+        public void run(){
+          f1.cancel()
+          executor.shutdownNow()
+        }
+      }, timeoutms, TimeUnit.MILLISECONDS)
+
+      executor.awaitTermination(timeoutms+10000, TimeUnit.MILLISECONDS)
+      executor.shutdownNow()
+    }
+
+  }
+  
+  doLast {
+    def found = false
+    stderr.toString().eachLine { line ->
+      if (line.contains(jalviewjs_desktop_init_string)) {
+        println("Found line '"+line+"'")
+        found = true
+        return
+      }
+    }
+    if (!found) {
+      throw new GradleException("Could not find evidence of Desktop launch in JalviewJS.")
+    }
+  }
+}
+  
+
 task jalviewjs {
   group "JalviewJS"
-  description "Build the site"
+  description "Build the JalviewJS site and run the launch test"
   dependsOn jalviewjsBuildSite
+  dependsOn jalviewjsLaunchTest
 }