diff --git a/Makefile b/Makefile index 6c307de..13d8af3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,12 @@ -clean: - ./gradlew clean assemble: ./gradlew assemble + +test: + ./gradlew test + +install: + ./gradlew publishToMavenLocal + +clean: + ./gradlew clean diff --git a/README.md b/README.md index eb97596..fe7bc1d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The `nextflowPlugin` block supports the following configuration options: - **`requirePlugins`** (optional) - List of plugin dependencies that must be present - **`extensionPoints`** (optional) - List of extension point class names provided by the plugin - **`useDefaultDependencies`** (optional, default: `true`) - Whether to automatically add default dependencies required for Nextflow plugin development +- **`generateSpec`** (optional, default: `true`) - Whether to generate a plugin spec file during the build. Set to `false` to skip spec file generation ### Registry Configuration diff --git a/adr/20251024-plugin-specification-generation-task.md b/adr/20251024-plugin-specification-generation-task.md new file mode 100644 index 0000000..db9bb8e --- /dev/null +++ b/adr/20251024-plugin-specification-generation-task.md @@ -0,0 +1,91 @@ +# ADR: Plugin Specification Generation Task + +## Context + +Nextflow plugins require a machine-readable specification file that describes their capabilities and extension points. The `GenerateSpecTask` automates the generation of this specification file (`spec.json`) using Nextflow's built-in tooling. + +## Implementation + +### Task Type +- **Extends**: `JavaExec` (not a standard task) +- **Rationale**: Must execute Java code from Nextflow core library to generate the specification +- **Location**: `src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy:34` + +### What Runs + +The task executes the Java class `nextflow.plugin.spec.PluginSpecWriter` from Nextflow core: + +```groovy +getMainClass().set('nextflow.plugin.spec.PluginSpecWriter') +``` + +**Arguments**: `[specFile path] + [list of extension point class names]` + +Example: `/path/to/spec.json com.example.MyExecutor com.example.MyTraceObserver` + +### Nextflow Dependency + +**Minimum Version**: Nextflow **25.09.0** +- Versions >= 25.09.0: Executes `PluginSpecWriter` to generate full specification +- Versions < 25.09.0: Creates empty spec file for backward compatibility + +**Dependency Resolution**: Uses dedicated `specFile` source set and configuration + +```groovy +configurations.create('specFile') +sourceSets.create('specFile') { + compileClasspath += configurations.specFile + runtimeClasspath += configurations.specFile +} +``` + +**Classpath includes**: +- `io.nextflow:nextflow:${nextflowVersion}` - provides PluginSpecWriter class +- Plugin's own JAR file - provides extension point classes for introspection + +### Output Format & Location + +**Format**: JSON file +**Path**: `build/resources/main/META-INF/spec.json` +**Packaging**: Included in plugin JAR at `META-INF/spec.json` + +The specification describes plugin structure and capabilities for Nextflow's plugin system to discover. + +### Task Configuration + +**Inputs**: +- `extensionPoints`: List of fully qualified class names implementing Nextflow extension points + +**Outputs**: +- `specFile`: The generated JSON specification + +**Execution order**: +1. Compile plugin classes (`jar`) +2. Compile specFile source set (`compileSpecFileGroovy`) +3. Execute `buildSpec` task + +### Version Detection Logic + +Simple integer parsing of `major.minor.patch` format: +- Splits on first two dots +- Compares: `major >= 25 && minor >= 9` +- Handles edge suffixes: `25.09.0-edge` → supported + +## Decision + +Generate plugin specification using Nextflow's own tooling rather than custom implementation to ensure: +- Compatibility with Nextflow's plugin system evolution +- Correct introspection of extension point capabilities +- Consistency across plugin ecosystem + +## Consequences + +**Positive**: +- Delegates specification format to Nextflow core +- Automatic compatibility with Nextflow's plugin discovery +- Empty file fallback maintains compatibility with older Nextflow versions + +**Negative**: +- Requires JavaExec complexity instead of simple file generation +- Circular dependency: needs compiled plugin JAR before generating spec +- Hard version cutoff at 25.09.0 (no graceful degradation between this version) \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0f9851d..10003fe 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,8 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'org.apache.httpcomponents:httpmime:4.5.14' - testImplementation('org.spockframework:spock-core:2.3-groovy-3.0') + testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'org.wiremock:wiremock:3.5.4' } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df97d72..2e11132 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy b/src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy new file mode 100644 index 0000000..8f1320b --- /dev/null +++ b/src/main/groovy/io/nextflow/gradle/GenerateSpecTask.groovy @@ -0,0 +1,118 @@ +package io.nextflow.gradle + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.OutputFile + +/** + * Gradle task to generate the plugin specification file for a Nextflow plugin. + * + *

This task creates a JSON specification file (spec.json) that describes the plugin's + * structure and capabilities. The spec file is used by Nextflow's plugin system to understand + * what extension points and functionality the plugin provides. + * + *

This task extends {@link JavaExec} because it needs to execute Java code from the + * Nextflow core library (specifically {@code nextflow.plugin.spec.PluginSpecWriter}) to + * generate the spec file. The JavaExec task type provides the necessary infrastructure to: + *

+ * + *

The task automatically checks if the configured Nextflow version supports plugin specs + * (version 25.09.0 or later). For earlier versions, it creates an empty spec file to maintain + * compatibility. + * + *

The generated spec file is placed at {@code build/resources/main/META-INF/spec.json} + * and is included in the plugin's JAR file. + * + * @author Ben Sherman + */ +abstract class GenerateSpecTask extends JavaExec { + + /** + * List of fully qualified class names that represent extension points provided by this plugin. + * These classes extend or implement Nextflow extension point interfaces. + */ + @Input + final ListProperty extensionPoints + + /** + * The output file where the plugin specification JSON will be written. + * Defaults to {@code build/resources/main/META-INF/spec.json}. + */ + @OutputFile + final RegularFileProperty specFile + + /** + * Constructor that configures the task to execute the PluginSpecWriter from Nextflow core. + * Sets up the classpath, main class, and arguments needed to generate the spec file. + */ + GenerateSpecTask() { + extensionPoints = project.objects.listProperty(String) + extensionPoints.convention(project.provider { + project.extensions.getByType(NextflowPluginConfig).extensionPoints + }) + + specFile = project.objects.fileProperty() + specFile.convention(project.layout.buildDirectory.file("resources/main/META-INF/spec.json")) + + getMainClass().set('nextflow.plugin.spec.PluginSpecWriter') + + project.afterEvaluate { + setClasspath(project.sourceSets.getByName('specFile').runtimeClasspath) + setArgs([specFile.get().asFile.toString()] + extensionPoints.get()) + } + + doFirst { + specFile.get().asFile.parentFile.mkdirs() + } + } + + /** + * Executes the task to generate the plugin spec file. + * Checks if the Nextflow version supports plugin specs (>= 25.09.0). + * For unsupported versions, creates an empty spec file instead. + */ + @Override + void exec() { + def config = project.extensions.getByType(NextflowPluginConfig) + if (!isVersionSupported(config.nextflowVersion)) { + createEmptySpecFile() + return + } + super.exec() + } + + /** + * Determines whether the given Nextflow version supports plugin specifications. + * Plugin specs are supported in Nextflow version 25.09.0 and later. + * + * @param nextflowVersion the Nextflow version string (e.g., "25.09.0-edge") + * @return true if the version supports plugin specs, false otherwise + */ + private boolean isVersionSupported(String nextflowVersion) { + try { + def parts = nextflowVersion.split(/\./, 3) + if (parts.length < 3) + return false + def major = Integer.parseInt(parts[0]) + def minor = Integer.parseInt(parts[1]) + return major >= 25 && minor >= 9 + } catch (Exception e) { + project.logger.warn("Unable to parse Nextflow version '${nextflowVersion}', assuming plugin spec is not supported: ${e.message}") + return false + } + } + + /** + * Creates an empty spec file for backward compatibility with Nextflow versions + * that don't support plugin specifications. + */ + private void createEmptySpecFile() { + specFile.get().asFile.text = '' + } +} diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy index 8d6ea9b..9bd5223 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy @@ -15,7 +15,9 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion * A gradle plugin for nextflow plugin projects. */ class NextflowPlugin implements Plugin { + private static final int JAVA_TOOLCHAIN_VERSION = 21 + private static final int JAVA_VERSION = 17 @Override @@ -59,6 +61,17 @@ class NextflowPlugin implements Plugin { reps.maven { url = "https://s3-eu-west-1.amazonaws.com/maven.seqera.io/releases" } } + // Create specFile source set early so configurations are available + if( config.generateSpec ) { + project.configurations.create('specFile') + if (!project.sourceSets.findByName('specFile')) { + project.sourceSets.create('specFile') { sourceSet -> + sourceSet.compileClasspath += project.configurations.getByName('specFile') + sourceSet.runtimeClasspath += project.configurations.getByName('specFile') + } + } + } + project.afterEvaluate { config.validate() final nextflowVersion = config.nextflowVersion @@ -66,10 +79,22 @@ class NextflowPlugin implements Plugin { if (config.useDefaultDependencies) { addDefaultDependencies(project, nextflowVersion) } + + // dependencies for generateSpec task + if( config.generateSpec ) { + project.dependencies { deps -> + deps.specFile "io.nextflow:nextflow:${nextflowVersion}" + deps.specFile project.files(project.tasks.jar.archiveFile) + } + } } + // use JUnit 5 platform project.test.useJUnitPlatform() + // sometimes tests depend on the assembled plugin + project.tasks.test.dependsOn << project.tasks.assemble + // ----------------------------------- // Add plugin details to jar manifest // ----------------------------------- @@ -88,21 +113,32 @@ class NextflowPlugin implements Plugin { project.tasks.jar.from(project.layout.buildDirectory.dir('resources/main')) project.tasks.compileTestGroovy.dependsOn << extensionPointsTask + // buildSpec - generates the plugin spec file + if( config.generateSpec ) { + project.tasks.register('buildSpec', GenerateSpecTask) + project.tasks.buildSpec.dependsOn << [ + project.tasks.jar, + project.tasks.compileSpecFileGroovy + ] + } + // packagePlugin - builds the zip file project.tasks.register('packagePlugin', PluginPackageTask) project.tasks.packagePlugin.dependsOn << [ project.tasks.extensionPoints, project.tasks.classes ] + project.afterEvaluate { + if( config.generateSpec ) + project.tasks.packagePlugin.dependsOn << project.tasks.buildSpec + } project.tasks.assemble.dependsOn << project.tasks.packagePlugin // installPlugin - installs plugin to (local) nextflow plugins dir project.tasks.register('installPlugin', PluginInstallTask) project.tasks.installPlugin.dependsOn << project.tasks.assemble - // sometimes tests depend on the assembled plugin - project.tasks.test.dependsOn << project.tasks.assemble - + // releasePlugin - publish plugin release to registry project.afterEvaluate { // Always create registry release task - it will use fallback configuration if needed project.tasks.register('releasePluginToRegistry', RegistryReleaseTask) diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy index ab9bc2d..bc897fd 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy @@ -16,6 +16,7 @@ import org.gradle.api.Project * publisher = 'nextflow' * className = 'com.example.ExamplePlugin' * useDefaultDependencies = false // optional, defaults to true + * generateSpec = false // optional, defaults to true * extensionPoints = [ * 'com.example.ExampleFunctions' * ] @@ -67,6 +68,11 @@ class NextflowPluginConfig { */ boolean useDefaultDependencies = true + /** + * Whether to generate a plugin spec (default: true) + */ + boolean generateSpec = true + /** * Configure registry publishing settings (optional) */ diff --git a/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy b/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy index fdcf8b2..fef5bd8 100644 --- a/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/PluginPackageTask.groovy @@ -31,7 +31,7 @@ abstract class PluginPackageTask extends Zip { } // Scan the sources to check that the declared main plugin classes is included - private void checkPluginClassIncluded(String className) { + protected void checkPluginClassIncluded(String className) { def sourceSets = project.extensions.getByType(SourceSetContainer) .named(SourceSet.MAIN_SOURCE_SET_NAME).get() diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy index 6b1a22c..d01e809 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryClient.groovy @@ -52,19 +52,20 @@ class RegistryClient { * * @param id The plugin identifier/name * @param version The plugin version (must be valid semver) - * @param file The plugin zip file to upload + * @param spec The plugin spec JSON file + * @param archive The plugin zip file to upload * @param provider The plugin provider * @throws RegistryReleaseException if the upload fails or returns an error */ - def release(String id, String version, File file, String provider) { + def release(String id, String version, File spec, File archive, String provider) { log.info("Releasing plugin ${id}@${version} using two-step upload") // Step 1: Create draft release with metadata - def releaseId = createDraftRelease(id, version, file, provider) + def releaseId = createDraftRelease(id, version, spec, archive, provider) log.debug("Created draft release with ID: ${releaseId}") // Step 2: Upload artifact and complete the release - uploadArtifact(releaseId, file) + uploadArtifact(releaseId, archive) log.info("Successfully released plugin ${id}@${version}") } @@ -76,21 +77,22 @@ class RegistryClient { * * @param id The plugin identifier/name * @param version The plugin version (must be valid semver) - * @param file The plugin zip file to upload + * @param spec The plugin spec to upload + * @param archive The plugin zip archive to upload * @param provider The plugin provider * @return Map with keys: success (boolean), skipped (boolean), message (String) * @throws RegistryReleaseException if the upload fails for reasons other than duplicates */ - def releaseIfNotExists(String id, String version, File file, String provider) { + def releaseIfNotExists(String id, String version, File spec, File archive, String provider) { log.info("Releasing plugin ${id}@${version} using two-step upload (if not exists)") try { // Step 1: Create draft release with metadata - def releaseId = createDraftRelease(id, version, file, provider) + def releaseId = createDraftRelease(id, version, spec, archive, provider) log.debug("Created draft release with ID: ${releaseId}") // Step 2: Upload artifact and complete the release - uploadArtifact(releaseId, file) + uploadArtifact(releaseId, archive) log.info("Successfully released plugin ${id}@${version}") return [success: true, skipped: false, message: null] @@ -113,12 +115,13 @@ class RegistryClient { * * @param id The plugin identifier/name * @param version The plugin version - * @param file The plugin zip file (used to compute checksum) + * @param spec The plugin spec to upload + * @param archive The plugin zip archive to upload * @param provider The plugin provider * @return The draft release ID * @throws RegistryReleaseException if the request fails */ - private Long createDraftRelease(String id, String version, File file, String provider) { + private Long createDraftRelease(String id, String version, File spec, File archive, String provider) { if (!provider) { throw new IllegalArgumentException("Plugin provider is required for plugin upload") } @@ -128,14 +131,15 @@ class RegistryClient { .build() // Calculate SHA-512 checksum - def fileBytes = Files.readAllBytes(file.toPath()) - def checksum = computeSha512(fileBytes) + def archiveBytes = Files.readAllBytes(archive.toPath()) + def checksum = computeSha512(archiveBytes) // Build JSON request body def requestBody = [ id: id, version: version, checksum: "sha512:${checksum}", + spec: spec?.text, provider: provider ] def jsonBody = JsonOutput.toJson(requestBody) @@ -176,16 +180,16 @@ class RegistryClient { * and publish it to the registry. * * @param releaseId The draft release ID from Step 1 - * @param file The plugin zip file to upload + * @param archive The plugin zip archive to upload * @throws RegistryReleaseException if the upload fails */ - private void uploadArtifact(Long releaseId, File file) { + private void uploadArtifact(Long releaseId, File archive) { def client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(30)) .build() def boundary = "----FormBoundary" + UUID.randomUUID().toString().replace("-", "") - def multipartBody = buildArtifactUploadBody(file, boundary) + def multipartBody = buildArtifactUploadBody(archive, boundary) def requestUri = URI.create(url.toString() + "v1/plugins/release/${releaseId}/upload") def request = HttpRequest.newBuilder() @@ -224,27 +228,25 @@ class RegistryClient { /** * Builds multipart body for Step 2 (artifact upload only). * - * @param file The plugin zip file to upload + * @param archive The plugin zip archive to upload * @param boundary The multipart boundary string * @return Multipart body as byte array */ - private byte[] buildArtifactUploadBody(File file, String boundary) { + private byte[] buildArtifactUploadBody(File archive, String boundary) { def output = new ByteArrayOutputStream() def writer = new PrintWriter(new OutputStreamWriter(output, "UTF-8"), true) def lineEnd = "\r\n" - // Read file bytes - def fileBytes = Files.readAllBytes(file.toPath()) - - // Add file field (changed from "artifact" to "payload" per API spec) + // Add archive field (changed from "artifact" to "payload" per API spec) writer.append("--${boundary}").append(lineEnd) - writer.append("Content-Disposition: form-data; name=\"payload\"; filename=\"${file.name}\"").append(lineEnd) + writer.append("Content-Disposition: form-data; name=\"payload\"; filename=\"${archive.name}\"").append(lineEnd) writer.append("Content-Type: application/zip").append(lineEnd) writer.append(lineEnd) writer.flush() - // Write file bytes - output.write(fileBytes) + // Write archive bytes + def archiveBytes = Files.readAllBytes(archive.toPath()) + output.write(archiveBytes) writer.append(lineEnd) writer.append("--${boundary}--").append(lineEnd) diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy index e9335e3..9197de6 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseIfNotExistsTask.groovy @@ -5,6 +5,7 @@ import io.nextflow.gradle.NextflowPluginConfig import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction /** @@ -24,6 +25,14 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { @InputFile final RegularFileProperty zipFile + /** + * The plugin spec file to be uploaded to the registry. + * By default, this points to the spec file created by the packagePlugin task. + */ + @InputFile + @Optional + final RegularFileProperty specFile + RegistryReleaseIfNotExistsTask() { group = 'Nextflow Plugin' description = 'Release the assembled plugin to the registry, skipping if already exists' @@ -33,6 +42,12 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { zipFile.convention(project.provider { buildDir.file("distributions/${project.name}-${project.version}.zip") }) + + specFile = project.objects.fileProperty() + specFile.convention(project.provider { + def file = buildDir.file("resources/main/META-INF/spec.json").asFile + file.exists() ? project.layout.projectDirectory.file(file.absolutePath) : null + }) } /** @@ -61,7 +76,8 @@ class RegistryReleaseIfNotExistsTask extends DefaultTask { def registryUri = new URI(registryConfig.resolvedUrl) def client = new RegistryClient(registryUri, registryConfig.resolvedAuthToken) - def result = client.releaseIfNotExists(project.name, version, project.file(zipFile), plugin.provider) as Map + def specFileValue = specFile.isPresent() ? project.file(specFile) : null + def result = client.releaseIfNotExists(project.name, version, specFileValue, project.file(zipFile), plugin.provider) as Map if (result.skipped as Boolean) { // Plugin already exists - log info message and continue diff --git a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy index 4b10b73..7f6d7cc 100644 --- a/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy +++ b/src/main/groovy/io/nextflow/gradle/registry/RegistryReleaseTask.groovy @@ -5,6 +5,7 @@ import io.nextflow.gradle.NextflowPluginConfig import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction /** @@ -23,6 +24,14 @@ class RegistryReleaseTask extends DefaultTask { @InputFile final RegularFileProperty zipFile + /** + * The plugin spec file to be uploaded to the registry. + * By default, this points to the spec file created by the packagePlugin task. + */ + @InputFile + @Optional + final RegularFileProperty specFile + RegistryReleaseTask() { group = 'Nextflow Plugin' description = 'Release the assembled plugin to the registry' @@ -32,6 +41,12 @@ class RegistryReleaseTask extends DefaultTask { zipFile.convention(project.provider { buildDir.file("distributions/${project.name}-${project.version}.zip") }) + + specFile = project.objects.fileProperty() + specFile.convention(project.provider { + def file = buildDir.file("resources/main/META-INF/spec.json").asFile + file.exists() ? project.layout.projectDirectory.file(file.absolutePath) : null + }) } /** @@ -58,7 +73,8 @@ class RegistryReleaseTask extends DefaultTask { def registryUri = new URI(registryConfig.resolvedUrl) def client = new RegistryClient(registryUri, registryConfig.resolvedAuthToken) - client.release(project.name, version, project.file(zipFile), plugin.provider) + def specFileValue = specFile.isPresent() ? project.file(specFile) : null + client.release(project.name, version, specFileValue, project.file(zipFile), plugin.provider) // Celebrate successful plugin upload! 🎉 project.logger.lifecycle("🎉 SUCCESS! Plugin '${project.name}' version ${version} has been successfully released to Nextflow Registry [${registryUri}]!") diff --git a/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy new file mode 100644 index 0000000..dd06d90 --- /dev/null +++ b/src/test/groovy/io/nextflow/gradle/GenerateSpecTaskTest.groovy @@ -0,0 +1,31 @@ +package io.nextflow.gradle + +import spock.lang.Specification + +/** + * + * @author Ben Sherman + */ +class GenerateSpecTaskTest extends Specification { + + def 'should determine whether Nextflow version is >=25.09.0-edge' () { + given: + def parts = VERSION.split(/\./, 3) + def major = Integer.parseInt(parts[0]) + def minor = Integer.parseInt(parts[1]) + def isSupported = major >= 25 && minor >= 9 + + expect: + isSupported == RESULT + + where: + VERSION | RESULT + '25.04.0' | false + '25.04.1' | false + '25.09.0-edge' | true + '25.09.1-edge' | true + '25.10.0' | true + '25.10.1' | true + } + +} diff --git a/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy b/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy index 6dfc88a..ed0169d 100644 --- a/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy +++ b/src/test/groovy/io/nextflow/gradle/registry/RegistryClientTest.groovy @@ -27,6 +27,18 @@ class RegistryClientTest extends Specification { wireMockServer?.stop() } + def createPluginArchive() { + def result = tempDir.resolve("test-plugin.zip").toFile() + result.text = "fake plugin zip content" + return result + } + + def createPluginSpec() { + def result = tempDir.resolve("spec.json").toFile() + result.text = "fake plugin spec content" + return result + } + def "should construct client with URL ending in slash"() { when: def client1 = new RegistryClient(new URI("http://example.com"), "token") @@ -47,8 +59,8 @@ class RegistryClientTest extends Specification { def "should successfully publish plugin using two-step process"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() // Step 1: Create draft release (JSON) wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) @@ -70,7 +82,7 @@ class RegistryClientTest extends Specification { .withBody('{"pluginRelease": {"status": "PUBLISHED"}}'))) when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: noExceptionThrown() @@ -85,15 +97,15 @@ class RegistryClientTest extends Specification { def "should throw RegistryReleaseException on HTTP error in draft creation without response body"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) .willReturn(aResponse() .withStatus(400))) when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -103,8 +115,8 @@ class RegistryClientTest extends Specification { def "should throw RegistryReleaseException on HTTP error in draft creation with response body"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) .willReturn(aResponse() @@ -112,7 +124,7 @@ class RegistryClientTest extends Specification { .withBody('{"error": "Plugin validation failed"}'))) when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -123,14 +135,14 @@ class RegistryClientTest extends Specification { def "should fail when connection error"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() // Stop the server to simulate connection error wireMockServer.stop() when: - client.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + client.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -141,11 +153,11 @@ class RegistryClientTest extends Specification { def "should fail when unknown host"(){ given: def clientNotfound = new RegistryClient(new URI("http://fake-host.fake-domain-blabla.com"), "token") - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() when: - clientNotfound.release("test-plugin", "1.0.0", pluginFile, "seqera.io") + clientNotfound.release("test-plugin", "1.0.0", pluginSpec, pluginArchive, "seqera.io") then: def ex = thrown(RegistryReleaseException) @@ -155,8 +167,8 @@ class RegistryClientTest extends Specification { def "should send correct JSON in two-step process"() { given: - def pluginFile = tempDir.resolve("test-plugin.zip").toFile() - pluginFile.text = "fake plugin zip content" + def pluginArchive = createPluginArchive() + def pluginSpec = createPluginSpec() // Step 1: Create draft with metadata (JSON) wireMockServer.stubFor(post(urlEqualTo("/api/v1/plugins/release")) @@ -169,7 +181,7 @@ class RegistryClientTest extends Specification { .willReturn(aResponse().withStatus(200))) when: - client.release("my-plugin", "2.1.0", pluginFile, "seqera.io") + client.release("my-plugin", "2.1.0", pluginSpec, pluginArchive, "seqera.io") then: // Verify Step 1: draft creation with JSON metadata @@ -179,6 +191,7 @@ class RegistryClientTest extends Specification { .withRequestBody(containing("\"id\":\"my-plugin\"")) .withRequestBody(containing("\"version\":\"2.1.0\"")) .withRequestBody(containing("\"checksum\":\"sha512:35ab27d09f1bc0d4a73b38fbd020064996fb013e2f92d3dd36bda7364765c229e90e0213fcd90c56fc4c9904e259c482cfaacb22dab327050d7d52229eb1a73c\"")) + .withRequestBody(containing("\"spec\":\"fake plugin spec content\"")) .withRequestBody(containing("\"provider\":\"seqera.io\""))) // Verify Step 2: artifact upload with multipart form data diff --git a/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy b/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy index 9e9305a..08e6f0f 100644 --- a/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy +++ b/src/test/groovy/io/nextflow/gradle/registry/RegistryReleaseTaskTest.groovy @@ -34,6 +34,11 @@ class RegistryReleaseTaskTest extends Specification { def testZip = tempDir.resolve("test-plugin-1.0.0.zip").toFile() testZip.text = "fake plugin content" task.zipFile.set(testZip) + + // Set up a test spec file + def testSpec = tempDir.resolve("spec.json").toFile() + testSpec.text = "fake plugin spec" + task.specFile.set(testSpec) } def "should use default fallback configuration when registry is not configured"() { @@ -166,4 +171,29 @@ class RegistryReleaseTaskTest extends Specification { // This proves the fallback configuration is working thrown(RegistryReleaseException) } + + def "should work when spec file is missing"() { + given: + project.ext['npr.apiKey'] = 'project-token' + project.ext['npr.apiUrl'] = 'https://project-registry.com/api' + project.nextflowPlugin { + description = 'A test plugin' + provider = 'Test Author' + className = 'com.example.TestPlugin' + nextflowVersion = '24.04.0' + extensionPoints = ['com.example.TestExtension'] + registry { + url = 'https://example.com/registry' + } + } + + // Don't set specFile - it should be optional + + when: + task.run() + + then: + // Should fail with connection error, not with missing file error + thrown(RegistryReleaseException) + } } \ No newline at end of file