diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleRun.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleRun.groovy index 7753545875..1a7961d920 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleRun.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/module/CmdModuleRun.groovy @@ -56,25 +56,27 @@ class CmdModuleRun extends CmdRun { @Override void run() { + final moduleFile = resolveModuleSource() + if( moduleFile ) { + args[0] = moduleFile.toAbsolutePath().toString() + super.run() + } + } + + protected Path resolveModuleSource() { if( !args ) { throw new AbortOperationException("Module name/path not provided") } - - final moduleFile = isLocalModule(args[0]) + return isLocalModule(args[0]) ? resolveLocalModule(args[0]) : resolveRemoteModule(args[0], version) - - if( moduleFile ) { - args[0] = moduleFile.toAbsolutePath().toString() - super.run() - } } private boolean isLocalModule(String str) { return str.startsWith('/') || str.startsWith('./') || str.startsWith('../') } - private Path resolveLocalModule(String str) { + protected Path resolveLocalModule(String str) { final module = Path.of(str).toAbsolutePath().normalize() final path = module.isDirectory() ? module.resolve(Const.DEFAULT_MAIN_FILE_NAME) : module if( !path.exists() ) @@ -82,7 +84,7 @@ class CmdModuleRun extends CmdRun { return path } - private Path resolveRemoteModule(String name, String version) { + protected Path resolveRemoteModule(String name, String version) { // Parse and validate module reference ModuleReference reference try { diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/module/CmdModuleRunTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/module/CmdModuleRunTest.groovy index 9157d43a66..eb9ee96a99 100644 --- a/modules/nextflow/src/test/groovy/nextflow/cli/module/CmdModuleRunTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/cli/module/CmdModuleRunTest.groovy @@ -16,311 +16,166 @@ package nextflow.cli.module -import io.seqera.npr.client.RegistryClient -import io.seqera.npr.api.schema.v1.Module -import io.seqera.npr.api.schema.v1.ModuleRelease import nextflow.cli.CliOptions import nextflow.cli.Launcher import nextflow.exception.AbortOperationException -import nextflow.module.ModuleReference -import nextflow.module.ModuleStorage -import org.apache.commons.compress.archivers.tar.TarArchiveEntry -import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream -import org.junit.Rule import spock.lang.Specification import spock.lang.TempDir -import test.OutputCapture import java.nio.file.Files import java.nio.file.Path -import java.util.regex.Pattern -import java.util.zip.GZIPOutputStream /** - * Tests for CmdModuleRun command + * Tests for CmdModuleRun source resolution * * @author Jorge Ejarque */ class CmdModuleRunTest extends Specification { - @Rule - OutputCapture capture = new OutputCapture() - @TempDir Path tempDir - def 'should run module and create output file'() { - given: - // Create a simple module script that creates a file - def moduleScript = ''' - process CREATE_FILE { - output: - path "test_output.txt" - - script: - """ - echo "Module executed successfully" > test_output.txt - """ - } - '''.stripIndent() + // --- Source classification (data-driven) --- + + def 'isLocalModule classifies "#source" as local=#expected'() { + expect: + new CmdModuleRun().isLocalModule(source) == expected + + where: + source || expected + '/absolute/path' || true + './relative/path' || true + '../parent/path' || true + '/tmp/module/main.nf' || true + 'nf-core/test-module' || false + 'scope/name' || false + 'single-word' || false + } - and: - // Create module directory structure - def storage = new ModuleStorage(tempDir) - def moduleRef = new ModuleReference('nf-core', 'test-module') - def moduleDir = storage.getModuleDir(moduleRef) - Files.createDirectories(moduleDir) + // --- Local resolution semantics --- - // Write main.nf - moduleDir.resolve('main.nf').text = moduleScript + def 'local module directory resolves to main.nf'() { + given: + def moduleDir = tempDir.resolve('my-module') + Files.createDirectories(moduleDir) + moduleDir.resolve('main.nf').text = '// local module' - // Write meta.yml - moduleDir.resolve('meta.yml').text = ''' - name: nf-core/test-module - version: 1.0.0 - description: Test module that creates a file - '''.stripIndent() + when: + def result = new CmdModuleRun().resolveLocalModule(moduleDir.toString()) - and: - // Create mock module package - def modulePackage = createModulePackage(moduleScript) - - // Mock registry client - def mockClient = Stub(RegistryClient) - def moduleRelease = new ModuleRelease() - moduleRelease.version = '1.0.0' - def module = new Module() - module.name = 'nf-core/test-module' - module.latest = moduleRelease - mockClient.getModule(_) >> module // Use wildcard to match any argument - mockClient.downloadModuleRelease(_, _, _) >> { String name, String version, Path dest -> - Files.write(dest, modulePackage) - return dest - } - def escapedPath = Pattern.quote(tempDir.toString()) - def pattern = ~/"${escapedPath}\/.+\/test_output\.txt"/ + then: + result == moduleDir.resolve('main.nf') + } - and: - def cmd = new CmdModuleRun() - def opts = new CliOptions() - opts.setQuiet(true) - cmd.launcher = Mock(Launcher) { - getOptions() >> opts - getCliString() >> "nextflow module run nf-core/test-module" - } - cmd.args = ['nf-core/test-module'] - cmd.root = tempDir - cmd.workDir = tempDir.toString() - cmd.outputDir = tempDir.resolve('results').toString() - cmd.outputFormat = 'json' - cmd.client = mockClient + def 'local module file resolves directly'() { + given: + def scriptFile = tempDir.resolve('custom.nf') + scriptFile.text = '// custom script' when: - cmd.run() - def stdout = capture - .toString() - .readLines()// remove the log part - .findResults { line -> !line.contains('DEBUG') ? line : null } - .findResults { line -> !line.contains('INFO') ? line : null }.join(" ") + def result = new CmdModuleRun().resolveLocalModule(scriptFile.toString()) then: - assert (stdout =~ pattern).find() - and: - // Verify module was installed - Files.exists(moduleDir) - Files.exists(moduleDir.resolve('main.nf')) - + result == scriptFile } - def 'should run module with specific version'() { - given: - def moduleScript = ''' - process CREATE_FILE_V2 { - output: - path "test_output_v2.txt", emit: output_path - - script: - """ - echo "Module version 2.0.0 executed successfully" > test_output_v2.txt - """ - } - ''' + // --- Source dispatch interactions --- - and: - def storage = new ModuleStorage(tempDir) - def moduleRef = new ModuleReference('nf-core', 'test-module') - def moduleDir = storage.getModuleDir(moduleRef) + def 'local module directory dispatches to local resolver and bypasses remote resolver'() { + given: + def moduleDir = tempDir.resolve('my-module') Files.createDirectories(moduleDir) - moduleDir.resolve('main.nf').text = moduleScript - moduleDir.resolve('meta.yml').text = 'name: nf-core/test-module\nversion: 2.0.0' - def escapedPath = Pattern.quote(tempDir.toString()) - def pattern = ~/"output_path": "${escapedPath}\/.+\/test_output_v2\.txt"/ - - and: - def modulePackage = createModulePackage(moduleScript) - - def mockClient = Mock(RegistryClient) - mockClient.downloadModuleRelease('nf-core/test-module', '2.0.0', _) >> { String name, String version, Path dest -> - Files.write(dest, modulePackage) - return dest - } + moduleDir.resolve('main.nf').text = '// local module' and: - def cmd = new CmdModuleRun() - def opts = new CliOptions() - opts.setQuiet(true) - cmd.launcher = Mock(Launcher) { - getOptions() >> opts - getCliString() >> "nextflow module run nf-core/test-module" - } - cmd.args = ['nf-core/test-module'] - cmd.version = '2.0.0' - cmd.root = tempDir - cmd.workDir = tempDir.toString() - cmd.outputDir = tempDir.resolve('results').toString() - cmd.outputFormat = 'json' - cmd.client = mockClient + def resolved = moduleDir.resolve('main.nf') + def cmd = Spy(CmdModuleRun) + cmd.args = [moduleDir.toString()] when: - cmd.run() + def result = cmd.resolveModuleSource() then: - def stdout = capture - .toString() - .readLines()// remove the log part - .findResults { line -> !line.contains('DEBUG') ? line : null } - .findResults { line -> !line.contains('INFO') ? line : null } - .findResults { line -> !line.contains('plugin') ? line : null }.join(" ") - assert (stdout =~ pattern).find() - + result == resolved + 1 * cmd.resolveLocalModule(moduleDir.toString()) >> resolved + 0 * cmd.resolveRemoteModule(_, _) } - def 'should run module from local path'() { + def 'local module file dispatches to local resolver and bypasses remote resolver'() { given: - def moduleScript = ''' - process CREATE_LOCAL_FILE { - output: - path "local_output.txt" - - script: - """ - echo "Local module executed" > local_output.txt - """ - } - '''.stripIndent() + def scriptFile = tempDir.resolve('custom.nf') + scriptFile.text = '// custom script' and: - // Create a local module directory with main.nf - def moduleDir = tempDir.resolve('local-module') - Files.createDirectories(moduleDir) - moduleDir.resolve('main.nf').text = moduleScript + def cmd = Spy(CmdModuleRun) + cmd.args = [scriptFile.toString()] - def escapedPath = Pattern.quote(tempDir.toString()) - def pattern = ~/"${escapedPath}\/.+\/local_output\.txt"/ + when: + def result = cmd.resolveModuleSource() - and: - def cmd = new CmdModuleRun() - def opts = new CliOptions() - opts.setQuiet(true) - cmd.launcher = Mock(Launcher) { - getOptions() >> opts - getCliString() >> "nextflow module run ${moduleDir}" - } - cmd.args = [moduleDir.toString()] - cmd.root = tempDir - cmd.workDir = tempDir.toString() - cmd.outputDir = tempDir.resolve('results').toString() - cmd.outputFormat = 'json' + then: + result == scriptFile + 1 * cmd.resolveLocalModule(scriptFile.toString()) >> scriptFile + 0 * cmd.resolveRemoteModule(_, _) + } + + def 'registry source dispatches to remote resolver and bypasses local resolver'() { + given: + def resolved = tempDir.resolve('modules/nf-core/test-module/main.nf') + def cmd = Spy(CmdModuleRun) + cmd.args = ['nf-core/test-module'] + cmd.launcher = Mock(Launcher) { getOptions() >> new CliOptions() } when: - cmd.run() - def stdout = capture - .toString() - .readLines()// remove the log part - .findResults { line -> !line.contains('DEBUG') ? line : null } - .findResults { line -> !line.contains('INFO') ? line : null }.join(" ") + def result = cmd.resolveModuleSource() then: - assert (stdout =~ pattern).find() + result == resolved + 1 * cmd.resolveRemoteModule('nf-core/test-module', null) >> resolved + 0 * cmd.resolveLocalModule(_) } - def 'should fail when path and module do not exist'() { + // --- Error cases --- + + def 'no arguments throws AbortOperationException'() { given: - def nonExistentPath = tempDir.resolve('does-not-exist').toString() def cmd = new CmdModuleRun() - cmd.launcher = Mock(Launcher) { - getOptions() >> null - } - cmd.args = [nonExistentPath] - cmd.root = tempDir + cmd.args = [] when: - cmd.run() + cmd.resolveModuleSource() then: def e = thrown(AbortOperationException) - e.message.contains('Invalid module path') + e.message.contains('Module name/path not provided') } - def 'should fail with no arguments'() { + def 'non-existent local path throws AbortOperationException'() { given: def cmd = new CmdModuleRun() - cmd.launcher = Mock(Launcher) { - getOptions() >> null - } - cmd.args = [] + cmd.args = [tempDir.resolve('does-not-exist').toString()] cmd.root = tempDir when: - cmd.run() + cmd.resolveModuleSource() then: - thrown(AbortOperationException) + def e = thrown(AbortOperationException) + e.message.contains('Invalid module path') } - def 'should fail with invalid module reference'() { + def 'invalid module reference format throws AbortOperationException'() { given: def cmd = new CmdModuleRun() - cmd.launcher = Mock(Launcher) { - getOptions() >> null - } - cmd.args = ['invalid-module'] // Missing scope + cmd.args = ['invalid-module'] cmd.root = tempDir + cmd.launcher = Mock(Launcher) { getOptions() >> new CliOptions() } when: - cmd.run() + cmd.resolveModuleSource() then: - thrown(AbortOperationException) - } - - // Helper method to create a module package (tar.gz) - private byte[] createModulePackage(String mainNfContent) { - def baos = new ByteArrayOutputStream() - - new GZIPOutputStream(baos).withCloseable { gzos -> - new TarArchiveOutputStream(gzos).withCloseable { tos -> - // Add main.nf - addTarEntry(tos, 'main.nf', mainNfContent.bytes) - - // Add meta.yml - def metaContent = ''' - name: test-module - version: 1.0.0 - description: Test module - '''.stripIndent() - addTarEntry(tos, 'meta.yml', metaContent.bytes) - } - } - - return baos.toByteArray() - } - - private void addTarEntry(TarArchiveOutputStream tos, String name, byte[] content) { - def entry = new TarArchiveEntry(name) - entry.setSize(content.length) - tos.putArchiveEntry(entry) - tos.write(content) - tos.closeArchiveEntry() + def e = thrown(AbortOperationException) + e.message.contains('Invalid module reference') } }