diff --git a/docs/docs/assertions/snapshots.md b/docs/docs/assertions/snapshots.md index 983c5cc1..b63c6612 100644 --- a/docs/docs/assertions/snapshots.md +++ b/docs/docs/assertions/snapshots.md @@ -105,6 +105,14 @@ You can also use helper methods to add objects to snapshots. For example, you ca assert snapshot(workflow, path(params.outdir).list()).match() ``` +## Compressed Snapshots + +If you add complex objects to snapshots with large content, you could use the `md5()` function to store the hashsum instead of the content in the snapshot file: + +```Groovy + assert snapshot(hugeObject).md5().match() +``` + ## File Paths If nf-test detects a path in the snapshot it automatically replace it by a unique *fingerprint* of the file that ensures the file content is the same. The fingerprint is default the md5 sum. diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index c978c1b2..d5426076 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -113,6 +113,10 @@ By understanding this profile evaluation order, you can effectively configure Ne ## File Staging +!!! warning + + File Staging is obsolete since version >= 0.9.0. + The `stage` section of the `nf-test.config` file is used to define files that are needed by Nextflow in the test environment (`meta` directory). Additionally, the directories `lib`, `bin`, and `assets` are automatically staged. ### Supported Directives diff --git a/docs/docs/testcases/setup.md b/docs/docs/testcases/setup.md index d58d070c..719d1143 100644 --- a/docs/docs/testcases/setup.md +++ b/docs/docs/testcases/setup.md @@ -38,6 +38,17 @@ run("WorkflowName") { } ``` +If you need to run the same process multiple times, you can set the alias of the process: + +```groovy +run("GENERATE_DATA", alias: "MY_PROCESS") { + script "./generate_data.nf" + process { + ... + } +} +``` + !!! warning Please keep in mind that changes in procsses or workflows, which are executed in the setup method, can result in a failed test run. @@ -153,3 +164,61 @@ nextflow_process { } ``` +### 3. Aliasing of Dependencies + +In this example, the process `UNTAR` is used multiple times in the setup method: + +```groovy +nextflow_process { + + ... + + setup { + + run("UNTAR", alias: "UNTAR1") { + script "modules/nf-core/untar/main.nf" + process { + """ + input[0] = Channel.fromList(...) + """ + } + } + + run("UNTAR", alias: "UNTAR2") { + script "modules/nf-core/untar/main.nf" + process { + """ + input[0] = Channel.fromList(...) + """ + } + } + + run("UNTAR", alias: "UNTAR3") { + script "modules/nf-core/untar/main.nf" + process { + """ + input[0] = Channel.fromList(...) + """ + } + } + } + + test("Test with three different inputs") { + when { + process { + """ + input[0] = UNTAR1.out.untar.map{ it[1] } + input[1] = UNTAR2.out.untar.map{ it[1] } + input[2] = UNTAR3.out.untar.map{ it[1] } + """ + } + } + + then { + ... + } + + } + +} +``` \ No newline at end of file diff --git a/docs/resources.md b/docs/resources.md index 33a1dd87..d05efa2f 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -9,6 +9,22 @@ This page collects videos and blog posts about nf-test created by the community. --- +### :material-tooltip-text:{ style="color: #337ab7" } Training Module: Hello nf-test + +This training modules introduces nf-test in a simple and easy to follow-along hello-nextflow style aimed for beginners. + +[hello nf-test training module](https://training.nextflow.io/hello_nextflow/05_hello_nf-test/) + +--- + +### :material-tooltip-text:{ style="color: #337ab7" } Blog post: Leveraging nf-test for enhanced quality control in nf-core + +Reproducibility is an important attribute of all good science. This is specially true in the realm of bioinformatics, where software is hopefully being constantly updated, and pipelines are ideally being maintained. This blog post covers nf-test in the nf-core context. + +[Read blog post](https://nextflow.io/blog/2024/nf-test-in-nf-core.html) + +--- + ### :fontawesome-brands-youtube:{ style="color: #EE0F0F" } nf-core/bytesize: Converting pytest modules to nf-test **Adam Talbot** & **Sateesh Peri** do a live demo of converting nf-core DSL2 modules pytests to nf-test diff --git a/pom.xml b/pom.xml index cb35d6cb..73cbd4bf 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 com.askimed nf-test - 0.8.3 + 0.8.4 nf-test Simple test framework for Nextflow pipelines https://github.com/askimed/nf-test diff --git a/src/main/java/com/askimed/nf/test/App.java b/src/main/java/com/askimed/nf/test/App.java index adcf0074..d9f3e8cf 100644 --- a/src/main/java/com/askimed/nf/test/App.java +++ b/src/main/java/com/askimed/nf/test/App.java @@ -17,7 +17,7 @@ public class App { public static final String NAME = "nf-test"; - public static final String VERSION = "0.8.3"; + public static final String VERSION = "0.8.4"; public static final String PACKAGE = "com.askimed.nf.test"; diff --git a/src/main/java/com/askimed/nf/test/commands/AbstractCommand.java b/src/main/java/com/askimed/nf/test/commands/AbstractCommand.java index 76e66dbc..fc2852f0 100644 --- a/src/main/java/com/askimed/nf/test/commands/AbstractCommand.java +++ b/src/main/java/com/askimed/nf/test/commands/AbstractCommand.java @@ -3,6 +3,7 @@ import java.util.Arrays; import java.util.concurrent.Callable; +import com.askimed.nf.test.nextflow.NextflowCommand; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,6 +34,7 @@ public Integer call() throws Exception { log.info(App.NAME + " " + App.VERSION); log.info("Arguments: " + Arrays.toString(App.args)); + log.info("Nextflow Version: " + NextflowCommand.getVersion()); if (!silent) { printHeader(); @@ -48,7 +50,7 @@ private void printHeader() { System.out.println(); System.out.println(Emoji.ROCKET + AnsiText.bold(" " + App.NAME + " " + App.VERSION)); System.out.println("https://code.askimed.com/nf-test"); - System.out.println("(c) 2021 - 2023 Lukas Forer and Sebastian Schoenherr"); + System.out.println("(c) 2021 - 2024 Lukas Forer and Sebastian Schoenherr"); System.out.println(); } diff --git a/src/main/java/com/askimed/nf/test/core/AbstractTest.java b/src/main/java/com/askimed/nf/test/core/AbstractTest.java index 236fa6a4..ff7675f1 100644 --- a/src/main/java/com/askimed/nf/test/core/AbstractTest.java +++ b/src/main/java/com/askimed/nf/test/core/AbstractTest.java @@ -2,17 +2,14 @@ import java.io.File; import java.io.IOException; -import java.math.BigInteger; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Vector; +import com.askimed.nf.test.util.HashUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.askimed.nf.test.config.Config; -import com.askimed.nf.test.config.FileStaging; import com.askimed.nf.test.util.FileUtil; public abstract class AbstractTest implements ITest { @@ -57,6 +54,8 @@ public abstract class AbstractTest implements ITest { public File projectDir = new File(System.getProperty("user.dir")); + public File mockFile; + public boolean skipped = false; protected File config = null; @@ -95,35 +94,27 @@ public File getConfig() { return config; } - @Override - public void setup(Config config, File testDirectory) throws IOException { + public void defineDirectories(File testDirectory) throws IOException { if (testDirectory == null) { throw new IOException("Testcase setup failed: No home directory set"); } - launchDir = initDirectory("Launch Directory", testDirectory, DIRECTORY_TESTS, getHash()); - metaDir = initDirectory("Meta Directory", launchDir, DIRECTORY_META); - outputDir = initDirectory("Output Directory", launchDir, DIRECTORY_OUTPUT); - workDir = initDirectory("Working Directory", launchDir, DIRECTORY_WORK); - FileStaging[] sharedDirectories = new FileStaging[]{ - new FileStaging("bin", config != null ? config.getStageMode() : FileStaging.MODE_COPY), - new FileStaging("lib", config != null ? config.getStageMode() : FileStaging.MODE_COPY), - new FileStaging("assets", config != null ? config.getStageMode() : FileStaging.MODE_COPY) - }; - try { - // copy bin, assets and lib to metaDir - shareDirectories(sharedDirectories, metaDir); - if (config != null) { - // copy user defined staging directories - log.debug("Stage {} user provided files...", config.getStageBuilder().getPaths().size()); - shareDirectories(config.getStageBuilder().getPaths(), metaDir); - } - shareDirectories(parent.getStageBuilder().getPaths(), metaDir); - } catch (Exception e) { - throw new IOException("Testcase setup failed: Directories could not be shared:\n" + e); - } + launchDir = constructDirectory(testDirectory, DIRECTORY_TESTS, getHash()); + metaDir = constructDirectory(launchDir, DIRECTORY_META); + outputDir = constructDirectory(launchDir, DIRECTORY_OUTPUT); + workDir = constructDirectory(launchDir, DIRECTORY_WORK); + mockFile = new File( ".nf-test-" + getHash() + ".nf"); + mockFile.deleteOnExit(); + } + + @Override + public void setup(Config config) throws IOException { + setupDirectory("Launch Directory", launchDir); + setupDirectory("Meta Directory", metaDir); + setupDirectory("Output Directory", outputDir); + setupDirectory("Working Directory", workDir); } @Override @@ -136,15 +127,17 @@ public void execute() throws Throwable { } } - public File initDirectory(String name, File root, String... childs) throws IOException { - + private File constructDirectory(File root, String... childs) { String path = FileUtil.path(root.getAbsolutePath(), FileUtil.path(childs)); - File directory = new File(path).getAbsoluteFile(); + return directory; + } + + private void setupDirectory(String name, File directory) throws IOException { + try { FileUtil.deleteDirectory(directory); FileUtil.createDirectory(directory); - return directory; } catch (Exception e) { throw new IOException(name + " '" + directory + "' could not be deleted or created:\n" + e); } @@ -153,7 +146,7 @@ public File initDirectory(String name, File root, String... childs) throws IOExc @Override public void cleanup() { - // FileUtil.deleteDirectory(metaDir); + } @Override @@ -180,23 +173,8 @@ public String getHash() { throw new RuntimeException("Error generating hash"); } - return hash(parent.getFilename() + getName()); - - } + return HashUtil.getMd5(parent.getFilename() + getName()); - private String hash(String value) { - - MessageDigest md; - try { - md = MessageDigest.getInstance("MD5"); - md.update(value.getBytes()); - byte[] md5sum = md.digest(); - BigInteger bigInt = new BigInteger(1, md5sum); - return bigInt.toString(16); - } catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - return "??"; - } } public void tag(String tag) { @@ -257,20 +235,6 @@ public boolean isWithTrace() { return withTrace; } - protected void shareDirectories(List directories, File stageDir) throws IOException { - for (FileStaging directory : directories) { - String metaDirectory = FileUtil.path(stageDir.getAbsolutePath(), directory.getPath()); - directory.stage(metaDirectory); - } - } - - protected void shareDirectories(FileStaging[] directories, File stageDir) throws IOException { - for (FileStaging directory : directories) { - String metaDirectory = FileUtil.path(stageDir.getAbsolutePath(), directory.getPath()); - directory.stage(metaDirectory); - } - } - @Override public void setUpdateSnapshot(boolean updateSnapshot) { this.updateSnapshot = updateSnapshot; diff --git a/src/main/java/com/askimed/nf/test/core/AbstractTestSuite.java b/src/main/java/com/askimed/nf/test/core/AbstractTestSuite.java index e2d17885..21f0f386 100644 --- a/src/main/java/com/askimed/nf/test/core/AbstractTestSuite.java +++ b/src/main/java/com/askimed/nf/test/core/AbstractTestSuite.java @@ -82,7 +82,7 @@ public void evalualteTestClosures() throws Throwable { Closure closure = namedClosure.closure; ITest test = getNewTestInstance(testName); - test.setup(config, getHomeDirectory()); + test.defineDirectories(getHomeDirectory()); closure.setDelegate(test); closure.setResolveStrategy(Closure.DELEGATE_ONLY); closure.call(); @@ -90,6 +90,11 @@ public void evalualteTestClosures() throws Throwable { } } + public void setupTest(ITest test) throws Throwable { + test.setup(config); + } + + protected abstract ITest getNewTestInstance(String name); public void setScript(String script) { diff --git a/src/main/java/com/askimed/nf/test/core/ITest.java b/src/main/java/com/askimed/nf/test/core/ITest.java index 40c9497d..180eb236 100644 --- a/src/main/java/com/askimed/nf/test/core/ITest.java +++ b/src/main/java/com/askimed/nf/test/core/ITest.java @@ -6,7 +6,9 @@ public interface ITest extends ITaggable { - public void setup(Config config, File homeDirectory) throws Throwable; + public void defineDirectories(File testDirectory) throws Throwable; + + public void setup(Config config) throws Throwable; public void execute() throws Throwable; diff --git a/src/main/java/com/askimed/nf/test/core/ITestSuite.java b/src/main/java/com/askimed/nf/test/core/ITestSuite.java index 895bd6c5..726bb45d 100644 --- a/src/main/java/com/askimed/nf/test/core/ITestSuite.java +++ b/src/main/java/com/askimed/nf/test/core/ITestSuite.java @@ -36,4 +36,6 @@ public interface ITestSuite extends ITaggable { public void evalualteTestClosures() throws Throwable; -} \ No newline at end of file + public void setupTest(ITest test) throws Throwable; + +} diff --git a/src/main/java/com/askimed/nf/test/core/TestExecutionEngine.java b/src/main/java/com/askimed/nf/test/core/TestExecutionEngine.java index 9c7dc6f2..6bacbfb2 100644 --- a/src/main/java/com/askimed/nf/test/core/TestExecutionEngine.java +++ b/src/main/java/com/askimed/nf/test/core/TestExecutionEngine.java @@ -199,6 +199,8 @@ public int execute() throws Throwable { log.info("Run test '{}'. type: {}", test, test.getClass().getName()); totalTests++; + testSuite.setupTest(test); + listener.executionStarted(test); TestExecutionResult result = new TestExecutionResult(test); test.setWithTrace(withTrace); diff --git a/src/main/java/com/askimed/nf/test/lang/Dependency.java b/src/main/java/com/askimed/nf/test/lang/Dependency.java index 1a274e4a..8c83ab25 100644 --- a/src/main/java/com/askimed/nf/test/lang/Dependency.java +++ b/src/main/java/com/askimed/nf/test/lang/Dependency.java @@ -2,16 +2,25 @@ import groovy.lang.Closure; +import java.util.Map; + public class Dependency { private String script; private String name; + private String alias; + private String mapping; - public Dependency(String name, Closure closure) { + public static final String ATTRIBUTE_ALIAS = "alias"; + + public Dependency(String name, Map attributes, Closure closure) { this.name = name; + if (attributes.containsKey(ATTRIBUTE_ALIAS)) { + this.alias = attributes.get(ATTRIBUTE_ALIAS).toString(); + } closure.setDelegate(this); closure.setResolveStrategy(Closure.DELEGATE_FIRST); closure.call(); @@ -54,6 +63,18 @@ public void setName(String name) { this.name = name; } + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public boolean hasAlias() { + return alias != null; + } + public String getMapping() { return mapping; } diff --git a/src/main/java/com/askimed/nf/test/lang/extensions/Snapshot.java b/src/main/java/com/askimed/nf/test/lang/extensions/Snapshot.java index 9db05d14..6370ce9b 100644 --- a/src/main/java/com/askimed/nf/test/lang/extensions/Snapshot.java +++ b/src/main/java/com/askimed/nf/test/lang/extensions/Snapshot.java @@ -1,11 +1,11 @@ package com.askimed.nf.test.lang.extensions; -import java.io.IOException; - +import com.askimed.nf.test.core.ITest; +import com.askimed.nf.test.util.ObjectUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.askimed.nf.test.core.ITest; +import java.io.IOException; public class Snapshot { @@ -27,6 +27,11 @@ public boolean match() throws IOException { return match(test.getName()); } + public Snapshot md5() { + actual = ObjectUtil.getMd5(actual); + return this; + } + public boolean match(String id) throws IOException { //check if match with this id was already called. --> duplicate snapshots. diff --git a/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFile.java b/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFile.java index c287f1f0..152b7e7b 100644 --- a/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFile.java +++ b/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFile.java @@ -61,6 +61,9 @@ public SnapshotFile(String filename) { String timestamp = object.get("timestamp").toString(); Object content = object.get("content"); SnapshotFileItem item = new SnapshotFileItem(timestamp, content); + if (object.containsKey("meta")) { + item.setMeta((Map) object.get("meta")); + } snapshots.put(id, item); } log.debug("Load snapshots from file '{}'", filename); diff --git a/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFileItem.java b/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFileItem.java index 2136f29d..1d28e1f9 100644 --- a/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFileItem.java +++ b/src/main/java/com/askimed/nf/test/lang/extensions/SnapshotFileItem.java @@ -2,9 +2,13 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import com.askimed.nf.test.App; import com.askimed.nf.test.lang.extensions.util.SnapshotDiffUtil; +import com.askimed.nf.test.nextflow.NextflowCommand; import groovy.json.JsonGenerator; import groovy.json.JsonOutput; @@ -14,16 +18,19 @@ public class SnapshotFileItem { private String timestamp; + private Map meta = new HashMap<>(); + public static DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); public SnapshotFileItem(Object content) { - this.timestamp = createTimestamp(); - this.content = content; + this(SnapshotFileItem.createTimestamp(), content); } public SnapshotFileItem(String timestamp, Object content) { this.timestamp = timestamp; this.content = content; + this.meta.put(App.NAME, App.VERSION); + this.meta.put("nextflow", NextflowCommand.getVersion()); } public Object getContent() { @@ -34,6 +41,14 @@ public String getTimestamp() { return timestamp; } + public Map getMeta() { + return meta; + } + + public void setMeta(Map meta) { + this.meta = meta; + } + @Override public boolean equals(Object object) { @@ -55,7 +70,7 @@ public boolean equals(Object object) { } - protected String createTimestamp() { + public static String createTimestamp() { return DateTimeFormatter.ISO_DATE_TIME.format(LocalDateTime.now()); } @@ -67,4 +82,5 @@ public String toString() { return prettyJson; } + } diff --git a/src/main/java/com/askimed/nf/test/lang/extensions/util/PathConverter.java b/src/main/java/com/askimed/nf/test/lang/extensions/util/PathConverter.java index 47d1d56f..4b254b00 100644 --- a/src/main/java/com/askimed/nf/test/lang/extensions/util/PathConverter.java +++ b/src/main/java/com/askimed/nf/test/lang/extensions/util/PathConverter.java @@ -26,6 +26,11 @@ public Object convert(Object value, String key) { if (!path.toFile().exists()) { throw new RuntimeException("Path " + path.toString() + " not found."); } + } else if (value instanceof File) { + path = ((File) value).toPath(); + if (!path.toFile().exists()) { + throw new RuntimeException("Path " + path.toString() + " not found."); + } } else { path = new File(value.toString()).toPath(); diff --git a/src/main/java/com/askimed/nf/test/lang/function/FunctionTest.java b/src/main/java/com/askimed/nf/test/lang/function/FunctionTest.java index 768ce64b..5a9489d5 100644 --- a/src/main/java/com/askimed/nf/test/lang/function/FunctionTest.java +++ b/src/main/java/com/askimed/nf/test/lang/function/FunctionTest.java @@ -109,8 +109,10 @@ public void execute() throws Throwable { } // Create workflow mock - File workflow = new File(metaDir, FILE_MOCK); - writeWorkflowMock(workflow); + writeWorkflowMock(mockFile); + + // Copy mock file in meta folder for debugging + FileUtil.copy(mockFile, new File(metaDir, FILE_MOCK)); context.getParams().put("nf_test_output", metaDir.getAbsolutePath()); @@ -121,7 +123,7 @@ public void execute() throws Throwable { File paramsFile = new File(metaDir, FILE_PARAMS); NextflowCommand nextflow = new NextflowCommand(); - nextflow.setScript(workflow.getAbsolutePath()); + nextflow.setScript(mockFile.getAbsolutePath()); nextflow.setParams(context.getParams()); for (String profile: parent.getProfiles()) { nextflow.addProfile(profile); diff --git a/src/main/java/com/askimed/nf/test/lang/process/ProcessContext.java b/src/main/java/com/askimed/nf/test/lang/process/ProcessContext.java index 8f22ce90..f4189467 100644 --- a/src/main/java/com/askimed/nf/test/lang/process/ProcessContext.java +++ b/src/main/java/com/askimed/nf/test/lang/process/ProcessContext.java @@ -1,6 +1,8 @@ package com.askimed.nf.test.lang.process; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Vector; import com.askimed.nf.test.core.ITest; @@ -50,11 +52,15 @@ public void evaluateProcessClosure() { } - public void run(String process, Closure closure) { - Dependency dependency = new Dependency(process, closure); + public void run(Map attributes, String process, Closure closure) { + Dependency dependency = new Dependency(process, attributes, closure); dependencies.add(dependency); } + public void run(String process, Closure closure) { + run(new LinkedHashMap(), process, closure); + } + public List getDependencies() { return dependencies; } diff --git a/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java b/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java index 42d3fdde..20d4a72b 100644 --- a/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java +++ b/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java @@ -108,8 +108,10 @@ public void execute() throws Throwable { context.evaluateProcessClosure(); // Create workflow mock - File workflow = new File(metaDir, FILE_MOCK); - writeWorkflowMock(workflow); + writeWorkflowMock(mockFile); + + // Copy mock file in meta folder for debugging + FileUtil.copy(mockFile, new File(metaDir, FILE_MOCK)); context.getParams().put("nf_test_output", metaDir.getAbsolutePath()); @@ -124,7 +126,7 @@ public void execute() throws Throwable { File paramsFile = new File(metaDir, FILE_PARAMS); NextflowCommand nextflow = new NextflowCommand(); - nextflow.setScript(workflow.getAbsolutePath()); + nextflow.setScript(mockFile.getAbsolutePath()); nextflow.setParams(context.getParams()); for (String profile: parent.getProfiles()) { nextflow.addProfile(profile); diff --git a/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowContext.java b/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowContext.java index a9ae6efc..babb26ff 100644 --- a/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowContext.java +++ b/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowContext.java @@ -1,6 +1,8 @@ package com.askimed.nf.test.lang.workflow; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Vector; import com.askimed.nf.test.core.ITest; @@ -46,11 +48,15 @@ public void setWorkflow(Workflow workflow) { this.workflow = workflow; } - public void run(String process, Closure closure) { - Dependency dependency = new Dependency(process, closure); + public void run(Map attributes, String process, Closure closure) { + Dependency dependency = new Dependency(process, attributes, closure); dependencies.add(dependency); } + public void run(String process, Closure closure) { + run(new LinkedHashMap(), process, closure); + } + public List getDependencies() { return dependencies; } diff --git a/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTest.java b/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTest.java index c595990d..16aedef9 100644 --- a/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTest.java +++ b/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTest.java @@ -121,8 +121,10 @@ public void execute() throws Throwable { context.evaluateWorkflowClosure(); // Create workflow mock - File workflow = new File(metaDir, FILE_MOCK); - writeWorkflowMock(workflow); + writeWorkflowMock(mockFile); + + // Copy mock file in meta folder for debugging + FileUtil.copy(mockFile, new File(metaDir, FILE_MOCK)); context.getParams().put("nf_test_output", metaDir.getAbsolutePath()); if (isDebug()) { @@ -136,7 +138,7 @@ public void execute() throws Throwable { File paramsFile = new File(metaDir, FILE_PARAMS); NextflowCommand nextflow = new NextflowCommand(); - nextflow.setScript(workflow.getAbsolutePath()); + nextflow.setScript(mockFile.getAbsolutePath()); nextflow.setParams(context.getParams()); for (String profile: parent.getProfiles()) { nextflow.addProfile(profile); diff --git a/src/main/java/com/askimed/nf/test/nextflow/NextflowCommand.java b/src/main/java/com/askimed/nf/test/nextflow/NextflowCommand.java index 7bd2d018..40bd63e1 100644 --- a/src/main/java/com/askimed/nf/test/nextflow/NextflowCommand.java +++ b/src/main/java/com/askimed/nf/test/nextflow/NextflowCommand.java @@ -296,6 +296,48 @@ public int printVersion() throws IOException { } + private static String version = null; + + public static String getVersion(){ + if (version == null){ + try { + version = new NextflowCommand().parseVersion(); + } catch (Exception e){ + version = "unknown"; + } + } + return version; + } + + public String parseVersion() throws IOException { + + if (binary == null) { + throw new IOException(ERROR); + } + + List args = new Vector(); + args.add("-version"); + + Command nextflow = new Command(binary); + nextflow.setParams(args); + nextflow.setSilent(true); + StringBuffer output = new StringBuffer(); + nextflow.writeStderr(output); + nextflow.execute(); + String versionPattern = "version (\\d+\\.\\d+\\.\\d+)"; + Pattern pattern = Pattern.compile(versionPattern); + Matcher matcher = pattern.matcher(output); + + if (matcher.find()) { + return matcher.group(1); + } else { + return "unknown"; + } + + } + + + protected void writeParamsJson(Map params, File paramsFile) throws IOException { BufferedWriter writer = new BufferedWriter(new FileWriter(paramsFile)); diff --git a/src/main/java/com/askimed/nf/test/util/Command.java b/src/main/java/com/askimed/nf/test/util/Command.java index 47d0e2d3..3e8faecb 100644 --- a/src/main/java/com/askimed/nf/test/util/Command.java +++ b/src/main/java/com/askimed/nf/test/util/Command.java @@ -18,6 +18,10 @@ public class Command { private String stderrFileName = null; + private StringBuffer stdout; + + private StringBuffer stderr; + public Command(String cmd, String... params) { this.cmd = cmd; this.params = params; @@ -38,6 +42,15 @@ public void setParams(List params) { } } + public void writeStdout(StringBuffer stdout) { + this.stdout = stdout; + } + + public void writeStderr(StringBuffer stderr) { + this.stderr = stderr; + } + + public void saveStdOut(String filename) { this.stdoutFileName = filename; } @@ -69,10 +82,12 @@ public int execute() { Process process = builder.start(); CommandStreamHandler handler = new CommandStreamHandler(process.getInputStream(), stdoutFileName); + handler.setStringBuffer(stdout); handler.setSilent(silent); Thread inputStreamHandler = new Thread(handler); CommandStreamHandler handler2 = new CommandStreamHandler(process.getErrorStream(), stderrFileName); + handler.setStringBuffer(stderr); handler2.setSilent(silent); Thread errorStreamHandler = new Thread(handler2); diff --git a/src/main/java/com/askimed/nf/test/util/CommandStreamHandler.java b/src/main/java/com/askimed/nf/test/util/CommandStreamHandler.java index 2bf77917..dfdd3292 100644 --- a/src/main/java/com/askimed/nf/test/util/CommandStreamHandler.java +++ b/src/main/java/com/askimed/nf/test/util/CommandStreamHandler.java @@ -10,6 +10,8 @@ public class CommandStreamHandler implements Runnable { private String filename = null; + private StringBuffer memory; + public CommandStreamHandler(InputStream is) { this.is = new BufferedReader(new InputStreamReader(is)); } @@ -23,6 +25,10 @@ public void setSilent(boolean silent) { this.silent = silent; } + public void setStringBuffer(StringBuffer memory) { + this.memory = memory; + } + public void setFilename(String filename) { this.filename = filename; } @@ -43,6 +49,9 @@ public void run() { String line = null; while ((line = is.readLine()) != null) { + if (memory != null) { + memory.append(line).append("\n"); + } if (!silent) { System.out.println(" > " + line); } diff --git a/src/main/java/com/askimed/nf/test/util/FileUtil.java b/src/main/java/com/askimed/nf/test/util/FileUtil.java index ea98c705..20d27b8f 100644 --- a/src/main/java/com/askimed/nf/test/util/FileUtil.java +++ b/src/main/java/com/askimed/nf/test/util/FileUtil.java @@ -1,14 +1,6 @@ package com.askimed.nf.test.util; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.io.InputStream; -import java.io.PushbackInputStream; +import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -189,4 +181,17 @@ public static String encodeBase64(byte[] bytes) { return encodedContent; } + public static void copy(File source, File dest) throws IOException { + try (InputStream is = new FileInputStream(source); + OutputStream os = new FileOutputStream(dest)) { + + byte[] buffer = new byte[1024]; + int length; + + // Read from the input stream and write to the output stream + while ((length = is.read(buffer)) > 0) { + os.write(buffer, 0, length); + } + } + } } diff --git a/src/main/java/com/askimed/nf/test/util/HashUtil.java b/src/main/java/com/askimed/nf/test/util/HashUtil.java new file mode 100644 index 00000000..8f94773c --- /dev/null +++ b/src/main/java/com/askimed/nf/test/util/HashUtil.java @@ -0,0 +1,24 @@ +package com.askimed.nf.test.util; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class HashUtil { + + public static String getMd5(String value) { + + MessageDigest md; + try { + md = MessageDigest.getInstance("MD5"); + md.update(value.getBytes()); + byte[] md5sum = md.digest(); + BigInteger bigInt = new BigInteger(1, md5sum); + return bigInt.toString(16); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + return "??"; + } + } + +} diff --git a/src/main/java/com/askimed/nf/test/util/ObjectUtil.java b/src/main/java/com/askimed/nf/test/util/ObjectUtil.java new file mode 100644 index 00000000..11e29940 --- /dev/null +++ b/src/main/java/com/askimed/nf/test/util/ObjectUtil.java @@ -0,0 +1,41 @@ +package com.askimed.nf.test.util; + +import com.askimed.nf.test.lang.extensions.SnapshotFile; +import groovy.json.JsonGenerator; +import groovy.json.JsonOutput; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class ObjectUtil { + + public static String getMd5(Object object) { + JsonGenerator jsonGenerator = SnapshotFile.createJsonGenerator(); + String json = jsonGenerator.toJson(object); + try { + return calculateMD5(JsonOutput.prettyPrint(json)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + + public static String calculateMD5(String input) throws NoSuchAlgorithmException { + // Get an instance of the MD5 message digest algorithm + MessageDigest md = MessageDigest.getInstance("MD5"); + + // Update the digest with the input string's bytes + md.update(input.getBytes()); + + // Get the hash value as an array of bytes + byte[] digest = md.digest(); + + // Convert the byte array to a hexadecimal string + StringBuilder result = new StringBuilder(); + for (byte b : digest) { + result.append(String.format("%02x", b)); + } + + return result.toString(); + } +} diff --git a/src/main/resources/com/askimed/nf/test/lang/function/WorkflowMock.nf b/src/main/resources/com/askimed/nf/test/lang/function/WorkflowMock.nf index f04b97bd..75566acd 100644 --- a/src/main/resources/com/askimed/nf/test/lang/function/WorkflowMock.nf +++ b/src/main/resources/com/askimed/nf/test/lang/function/WorkflowMock.nf @@ -21,7 +21,7 @@ include { ${include} } from '${script}' def jsonOutput = new JsonGenerator.Options() .excludeNulls() // Do not include fields with value null.. - .addConverter(Path) { value -> value.toString() } // Custom converter for Path. Only filename + .addConverter(Path) { value -> value.toAbsolutePath().toString() } // Custom converter for Path. Only filename .build() diff --git a/src/main/resources/com/askimed/nf/test/lang/process/WorkflowMock.nf b/src/main/resources/com/askimed/nf/test/lang/process/WorkflowMock.nf index d1d77b51..d006888e 100644 --- a/src/main/resources/com/askimed/nf/test/lang/process/WorkflowMock.nf +++ b/src/main/resources/com/askimed/nf/test/lang/process/WorkflowMock.nf @@ -8,7 +8,7 @@ params.nf_test_output = "" // include dependencies <% for (dependency in dependencies) { %> -include { ${dependency.name} } from '${dependency.script}' +include { ${dependency.name} ${dependency.hasAlias() ? " as " + dependency.alias : "" } } from '${dependency.script}' <% } %> // include test process @@ -18,7 +18,7 @@ include { ${process} } from '${script}' def jsonOutput = new JsonGenerator.Options() .excludeNulls() // Do not include fields with value null.. - .addConverter(Path) { value -> value.toString() } // Custom converter for Path. Only filename + .addConverter(Path) { value -> value.toAbsolutePath().toString() } // Custom converter for Path. Only filename .build() @@ -29,7 +29,7 @@ workflow { { def input = [] ${dependency.mapping} - ${dependency.name}(*input) + ${dependency.hasAlias() ? dependency.alias : dependency.name}(*input) } <% } %> diff --git a/src/main/resources/com/askimed/nf/test/lang/workflow/WorkflowMock.nf b/src/main/resources/com/askimed/nf/test/lang/workflow/WorkflowMock.nf index 268bec33..126157ca 100644 --- a/src/main/resources/com/askimed/nf/test/lang/workflow/WorkflowMock.nf +++ b/src/main/resources/com/askimed/nf/test/lang/workflow/WorkflowMock.nf @@ -8,7 +8,7 @@ params.nf_test_output = "" // include dependencies <% for (dependency in dependencies) { %> -include { ${dependency.name} } from '${dependency.script}' +include { ${dependency.name} ${dependency.hasAlias() ? " as " + dependency.alias : "" } } from '${dependency.script}' <% } %> // include test workflow @@ -18,7 +18,7 @@ include { ${workflow} } from '${script}' def jsonOutput = new JsonGenerator.Options() .excludeNulls() // Do not include fields with value null.. - .addConverter(Path) { value -> value.toString() } // Custom converter for Path. Only filename + .addConverter(Path) { value -> value.toAbsolutePath().toString() } // Custom converter for Path. Only filename .build() @@ -29,7 +29,7 @@ workflow { { def input = [] ${dependency.mapping} - ${dependency.name}(*input) + ${dependency.hasAlias() ? dependency.alias : dependency.name}(*input) } <% } %> diff --git a/src/test/java/com/askimed/nf/test/lang/ProcessTest.java b/src/test/java/com/askimed/nf/test/lang/ProcessTest.java index d2d339dd..91b2783b 100644 --- a/src/test/java/com/askimed/nf/test/lang/ProcessTest.java +++ b/src/test/java/com/askimed/nf/test/lang/ProcessTest.java @@ -221,9 +221,17 @@ public void testScriptWithRelativePathInSubfolder() throws Exception { public void testDependencies() throws Exception { App app = new App(); - int exitCode = app.run(new String[] { "test", "test-data/process/dependencies/process_data.nf.test" }); + int exitCode = app.run(new String[] { "test", "test-data/process/dependencies/process_data.nf.test", "--verbose" }); - } + } + + @Test + public void testDependenciesWithAlias() throws Exception { + + App app = new App(); + int exitCode = app.run(new String[] { "test", "test-data/process/dependencies/process_data_alias.nf.test", "--verbose" }); + + } @Test public void testDependenciesAbricate() throws Exception { @@ -315,4 +323,13 @@ public void testNotUniquenessOfSnapshots() throws Exception { } + @Test + public void testMd5Snapshots() throws Exception { + + App app = new App(); + int exitCode = app.run(new String[] { "test", "test-data/process/snapshots/md5.nf.test" }); + assertEquals(0, exitCode); + + } + } diff --git a/src/test/java/com/askimed/nf/test/lang/WorkflowTest.java b/src/test/java/com/askimed/nf/test/lang/WorkflowTest.java index bd009076..87e147c6 100644 --- a/src/test/java/com/askimed/nf/test/lang/WorkflowTest.java +++ b/src/test/java/com/askimed/nf/test/lang/WorkflowTest.java @@ -139,8 +139,9 @@ public void testIssue125() throws Exception { public void testStagingWithoutMapping() throws Exception { App app = new App(); - int exitCode = app.run(new String[] { "test", "test-data/workflow/staging/hello.nf.test" }); - assertEquals(1, exitCode); + // TODO: remove this test. no staging needed. + //int exitCode = app.run(new String[] { "test", "test-data/workflow/staging/hello.nf.test" }); + //assertEquals(1, exitCode); } @@ -183,4 +184,13 @@ public void testStagingInTestsuite() throws Exception { } + @Test + public void testRegex() throws Exception { + + App app = new App(); + int exitCode = app.run(new String[] { "test", "test-data/workflow/regex/workflow.nf.test" }); + assertEquals(0, exitCode); + + } + } diff --git a/test-data/process/dependencies/process_data_alias.nf.test b/test-data/process/dependencies/process_data_alias.nf.test new file mode 100644 index 00000000..46556cfe --- /dev/null +++ b/test-data/process/dependencies/process_data_alias.nf.test @@ -0,0 +1,36 @@ +nextflow_process { + + name "Test process data" + + script "./process_data.nf" + process "PROCESS_DATA" + + test("Should use process GENERATE_DATA with alias PROCESS_ALIAS to generate input data") { + + setup { + run("GENERATE_DATA", alias: "LUKAS") { + script "./generate_data.nf" + process { + """ + input[0] = "nf-core" + """ + } + } + } + + when { + process { + """ + input[0] = "lukas" + input[1] = LUKAS.out.results + """ + } + } + + then { + assert process.success + assert snapshot(process.out.results).match() + } + } + +} diff --git a/test-data/process/snapshots/md5.nf.test b/test-data/process/snapshots/md5.nf.test new file mode 100644 index 00000000..1ab9e194 --- /dev/null +++ b/test-data/process/snapshots/md5.nf.test @@ -0,0 +1,36 @@ +nextflow_process { + + name "Test process xy" + + script "./process.nf" + process "TEST_PROCESS" + + test("Should succeed because two unique snapshots") { + + when { + process { + """ + input[0] = "Lukas" + """ + } + } + + then { + + def content = [ + object1: "lukas", + object2: 27, + object3: [ + a: "lll", + b: [1,2,3,4,5,6] + ] + ] + + assert process.success + assert snapshot(content).match() + assert snapshot(content).md5().match("lukas") + } + + } + +} diff --git a/test-data/process/snapshots/md5.nf.test.snap b/test-data/process/snapshots/md5.nf.test.snap new file mode 100644 index 00000000..7db05665 --- /dev/null +++ b/test-data/process/snapshots/md5.nf.test.snap @@ -0,0 +1,26 @@ +{ + "Should succeed because two unique snapshots": { + "content": [ + { + "object1": "lukas", + "object2": 27, + "object3": { + "a": "lll", + "b": [ + 1, + 2, + 3, + 4, + 5, + 6 + ] + } + } + ], + "timestamp": "2024-01-28T11:41:29.018819" + }, + "lukas": { + "content": "a54d97026c5ed6e6a57ccd3a3f5a3cbc", + "timestamp": "2024-01-28T11:41:29.025981" + } +} \ No newline at end of file diff --git a/test-data/workflow/regex/params.yaml b/test-data/workflow/regex/params.yaml new file mode 100644 index 00000000..455390bc --- /dev/null +++ b/test-data/workflow/regex/params.yaml @@ -0,0 +1,2 @@ +localOutputDir: "example_output" +someRegex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\$' \ No newline at end of file diff --git a/test-data/workflow/regex/workflow.nf b/test-data/workflow/regex/workflow.nf new file mode 100644 index 00000000..d8d5fbb3 --- /dev/null +++ b/test-data/workflow/regex/workflow.nf @@ -0,0 +1,36 @@ +process exampleProc { + + storeDir "${outputDir}/exampleProc" + input: + tuple( + val(outputDir), + val(localOutputDir), + val(someRegexString) + ) + + output: + tuple( + val(outputDir), + path("example_output/example*.txt") + ) + + shell: + """ + mkdir -p !{localOutputDir} + echo !{someRegexString} > "!{localOutputDir}/example1.txt" + """ +} + + +workflow PipeWf { + take: + inputCh + + main: + inputCh + | exampleProc + | set { outputCh } + + emit: + outputCh +} \ No newline at end of file diff --git a/test-data/workflow/regex/workflow.nf.test b/test-data/workflow/regex/workflow.nf.test new file mode 100644 index 00000000..41a5214f --- /dev/null +++ b/test-data/workflow/regex/workflow.nf.test @@ -0,0 +1,27 @@ +nextflow_workflow { + + name "Test workflow" + script "./workflow.nf" + workflow "PipeWf" + + test("Output will exist in default outputDir") { + + when { + + params { + outDir = "$outputDir" + load("test-data/workflow/regex/params.yaml") + } + workflow { + """ + input[0] = Channel.of([params.outDir,params.localOutputDir]) + """ + } + } + + then { + assert workflow.success + assert path("${outputDir}/exampleProc/example_output").exists() + } + } +} \ No newline at end of file