From febdfa4ca5f4e242ababdd8a9942d67ebfc3adc9 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Fri, 7 Nov 2025 17:20:14 -0600 Subject: [PATCH 1/2] Move task error formatting logic into separate class Signed-off-by: Ben Sherman --- .../processor/TaskErrorFormatter.groovy | 241 ++++++++++++++++++ .../nextflow/processor/TaskProcessor.groovy | 199 +-------------- 2 files changed, 246 insertions(+), 194 deletions(-) create mode 100644 modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy new file mode 100644 index 0000000000..8a81e597d6 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy @@ -0,0 +1,241 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package nextflow.processor + +import java.lang.reflect.InvocationTargetException +import java.nio.file.FileSystems +import java.nio.file.NoSuchFileException +import java.nio.file.Path + +import groovy.transform.CompileStatic +import groovy.transform.Memoized +import groovy.util.logging.Slf4j +import nextflow.exception.FailedGuardException +import nextflow.exception.ProcessEvalException +import nextflow.exception.ShowOnlyExceptionMessage +import nextflow.plugin.Plugins +import nextflow.processor.tip.TaskTipProvider +import nextflow.util.LoggerHelper +/** + * Implement formatting of standard task errors. + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class TaskErrorFormatter { + + /** + * Format the error message for an eval output error. + * + * @param message + * @param error + * @param task + */ + public List formatCommandError(List message, ProcessEvalException error, TaskRun task) { + // compose a readable error message + message << formatErrorCause(error) + + // - print the executed command + message << "Command executed:\n" + for( final line : error.command.stripIndent(true)?.trim()?.readLines() ) + message << " ${line}".toString() + + // - the exit status + message << "\nCommand exit status:\n ${error.status}".toString() + + // - the tail of the process stdout + message << "\nCommand output:" + + final lines = error.output.readLines() + if( lines.size() == 0 ) + message << " (empty)" + for( final line : lines ) + message << " ${stripWorkDir(line, task.workDir)}".toString() + + if( task?.workDir ) + message << "\nWork dir:\n ${task.workDirStr}".toString() + + return message + } + + /** + * Format the error message for a guard error (e.g. `when:`). + * + * @param message + * @param error + * @param task + */ + public List formatGuardError(List message, FailedGuardException error, TaskRun task) { + // compose a readable error message + message << formatErrorCause(error) + + if( error.source ) { + message << "\nWhen block:" + for( final line : error.source.stripIndent(true).readLines() ) + message << " ${line}".toString() + } + + if( task?.workDir ) + message << "\nWork dir:\n ${task.workDirStr}".toString() + + return message + } + + /** + * Format the error message for a task error. + * + * @param message + * @param error + * @param task + */ + public List formatTaskError(List message, Throwable error, TaskRun task) { + + // prepend a readable error message + message << formatErrorCause( error ) + + // task with `script:` block + if( task?.script ) { + // -- print the executed command + message << "Command executed${task.template ? " [$task.template]": ''}:\n".toString() + for( final line : task.script?.stripIndent(true)?.trim()?.readLines() ) + message << " ${line}".toString() + + // -- the exit status + message << "\nCommand exit status:\n ${task.exitStatus != Integer.MAX_VALUE ? task.exitStatus : '-'}".toString() + + // -- the tail of the process stdout + message << "\nCommand output:" + final max = 50 + final stdoutLines = task.dumpStdout(max) + if( stdoutLines.size() == 0 ) + message << " (empty)" + for( final line : stdoutLines ) + message << " ${stripWorkDir(line, task.workDir)}".toString() + + // -- the tail of the process stderr + final stderrLines = task.dumpStderr(max) + if( stderrLines ) { + message << "\nCommand error:" + for( final line : stderrLines ) + message << " ${stripWorkDir(line, task.workDir)}".toString() + } + + // -- this is likely a task wrapper issue + else if( task.exitStatus != 0 ) { + final logLines = task.dumpLogFile(max) + if( logLines ) { + message << "\nCommand log:" + for( final line : logLines ) + message << " ${stripWorkDir(line, task.workDir)}".toString() + } + } + } + + // task with `exec:` block + else if( task?.source ) { + message << "Source block:" + for( final line : task.source.stripIndent(true).readLines() ) + message << " ${line}".toString() + } + + // append work dir if present + if( task?.workDir ) + message << "\nWork dir:\n ${task.workDirStr}".toString() + + // append container image if present + if( task?.isContainerEnabled() ) + message << "\nContainer:\n ${task.container}".toString() + + // append tip + message << suggestTip(message) + + return message + } + + /** + * Format the cause of an error. + * + * @param error + */ + public String formatErrorCause(Throwable error) { + + final result = new StringBuilder() + result.append('\nCaused by:\n') + + final message = error instanceof ShowOnlyExceptionMessage || !error.cause + ? err0(error) + : err0(error.cause) + + for( final line : message.readLines() ) + result.append(' ').append(line).append('\n') + + return result.append('\n').toString() + } + + private static String err0(Throwable e) { + final err = e instanceof InvocationTargetException ? e.targetException : e + + if( err instanceof NoSuchFileException ) { + return "No such file or directory: $err.message" + } + + if( err instanceof MissingPropertyException ) { + def name = err.property ?: LoggerHelper.getDetailMessage(err) + def result = "No such variable: ${name}" + def details = LoggerHelper.findErrorLine(err) + if( details ) + result += " -- Check script '${details[0]}' at line: ${details[1]}" + return result + } + + def result = err.message ?: err.toString() + def details = LoggerHelper.findErrorLine(err) + if( details ) { + result += " -- Check script '${details[0]}' at line: ${details[1]}" + } + return result + } + + private static String stripWorkDir(String line, Path workDir) { + if( workDir == null ) + return line + + if( workDir.fileSystem != FileSystems.default ) + return line + + return workDir ? line.replace(workDir.toString() + '/', '') : line + } + + private static String suggestTip(List message) { + try { + return "\nTip: ${getTipProvider().suggestTip(message)}" + } + catch (Exception e) { + log.debug "Unable to get tip for task message: $message", e + return '' + } + } + + @Memoized + private static TaskTipProvider getTipProvider() { + final provider = Plugins.getPriorityExtensions(TaskTipProvider).find(it-> it.enabled()) + if( !provider ) + throw new IllegalStateException("Unable to find any tip provider") + return provider + } + +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy index ff7321402b..f0434e69c7 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy @@ -17,9 +17,7 @@ package nextflow.processor import static nextflow.processor.ErrorStrategy.* -import java.lang.reflect.InvocationTargetException import java.nio.file.FileSystems -import java.nio.file.NoSuchFileException import java.nio.file.Path import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -62,7 +60,6 @@ import nextflow.exception.ProcessFailedException import nextflow.exception.ProcessRetryableException import nextflow.exception.ProcessSubmitTimeoutException import nextflow.exception.ProcessUnrecoverableException -import nextflow.exception.ShowOnlyExceptionMessage import nextflow.exception.UnexpectedException import nextflow.executor.CachedTaskHandler import nextflow.executor.Executor @@ -72,8 +69,6 @@ import nextflow.extension.DataflowHelper import nextflow.file.FileHelper import nextflow.file.FileHolder import nextflow.file.FilePorter -import nextflow.plugin.Plugins -import nextflow.processor.tip.TaskTipProvider import nextflow.script.BaseScript import nextflow.script.BodyDef import nextflow.script.ProcessConfig @@ -108,7 +103,6 @@ import nextflow.util.CacheHelper import nextflow.util.Escape import nextflow.util.HashBuilder import nextflow.util.LockManager -import nextflow.util.LoggerHelper import nextflow.util.TestOnly import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer @@ -376,14 +370,6 @@ class TaskProcessor { boolean hasErrors() { errorCount>0 } - @Memoized - protected TaskTipProvider getTipProvider() { - final provider = Plugins.getPriorityExtensions(TaskTipProvider).find(it-> it.enabled()) - if( !provider ) - throw new IllegalStateException("Unable to find any tip provider") - return provider - } - boolean isSingleton() { singleton } /** @@ -1125,22 +1111,23 @@ class TaskProcessor { } def dumpStackTrace = log.isTraceEnabled() + def errorFormatter = new TaskErrorFormatter() message << "Error executing process > '${safeTaskName(task)}'" switch( error ) { case ProcessException: - formatTaskError( message, error, task ) + errorFormatter.formatTaskError( message, error, task ) break case ProcessEvalException: - formatCommandError( message, error, task ) + errorFormatter.formatCommandError( message, error, task ) break case FailedGuardException: - formatGuardError( message, error as FailedGuardException, task ) + errorFormatter.formatGuardError( message, error as FailedGuardException, task ) break; default: - message << formatErrorCause(error) + message << errorFormatter.formatErrorCause(error) dumpStackTrace = true } @@ -1206,139 +1193,6 @@ class TaskProcessor { return action } - final protected List formatCommandError(List message, ProcessEvalException error, TaskRun task) { - // compose a readable error message - message << formatErrorCause(error) - - // - print the executed command - message << "Command executed:\n" - error.command.stripIndent(true)?.trim()?.eachLine { - message << " ${it}" - } - - // - the exit status - message << "\nCommand exit status:\n ${error.status}" - - // - the tail of the process stdout - message << "\nCommand output:" - def lines = error.output.readLines() - if( lines.size() == 0 ) { - message << " (empty)" - } - for( String it : lines ) { - message << " ${stripWorkDir(it, task.workDir)}" - } - - if( task?.workDir ) - message << "\nWork dir:\n ${task.workDirStr}" - - return message - } - - final protected List formatGuardError( List message, FailedGuardException error, TaskRun task ) { - // compose a readable error message - message << formatErrorCause(error) - - if( error.source ) { - message << "\nWhen block:" - error.source.stripIndent(true).eachLine { - message << " $it" - } - } - - if( task?.workDir ) - message << "\nWork dir:\n ${task.workDirStr}" - - return message - } - - final protected List formatTaskError( List message, Throwable error, TaskRun task ) { - - // compose a readable error message - message << formatErrorCause( error ) - - /* - * task executing scriptlets - */ - if( task?.script ) { - // - print the executed command - message << "Command executed${task.template ? " [$task.template]": ''}:\n" - task.script?.stripIndent(true)?.trim()?.eachLine { - message << " ${it}" - } - - // - the exit status - message << "\nCommand exit status:\n ${task.exitStatus != Integer.MAX_VALUE ? task.exitStatus : '-'}" - - // - the tail of the process stdout - message << "\nCommand output:" - final max = 50 - def lines = task.dumpStdout(max) - if( lines.size() == 0 ) { - message << " (empty)" - } - for( String it : lines ) { - message << " ${stripWorkDir(it, task.workDir)}" - } - - // - the tail of the process stderr - lines = task.dumpStderr(max) - if( lines ) { - message << "\nCommand error:" - for( String it : lines ) { - message << " ${stripWorkDir(it, task.workDir)}" - } - } - // - this is likely a task wrapper issue - else if( task.exitStatus != 0 ) { - lines = task.dumpLogFile(max) - if( lines ) { - message << "\nCommand wrapper:" - for( String it : lines ) { - message << " ${stripWorkDir(it, task.workDir)}" - } - } - } - - } - else { - if( task?.source ) { - message << "Source block:" - task.source.stripIndent(true).eachLine { - message << " $it" - } - } - - } - - if( task?.workDir ) - message << "\nWork dir:\n ${task.workDirStr}" - - if( task?.isContainerEnabled() ) - message << "\nContainer:\n ${task.container}".toString() - - message << suggestTip(message) - - return message - } - - private String suggestTip(List message) { - try { - return "\nTip: ${getTipProvider().suggestTip(message)}" - } - catch (Exception e) { - log.debug "Unable to get tip for task message: $message", e - return '' - } - } - - private static String stripWorkDir(String line, Path workDir) { - if( workDir==null ) return line - if( workDir.fileSystem != FileSystems.default ) return line - return workDir ? line.replace(workDir.toString()+'/','') : line - } - - /** * Send a poison pill over all the outputs channel */ @@ -1363,49 +1217,6 @@ class TaskProcessor { } } - private String formatErrorCause( Throwable error ) { - - def result = new StringBuilder() - result << '\nCaused by:\n' - - def message - if( error instanceof ShowOnlyExceptionMessage || !error.cause ) - message = err0(error) - else - message = err0(error.cause) - - for( String line : message.readLines() ) { - result << ' ' << line << '\n' - } - - result - .append('\n') - .toString() - } - - - static String err0(Throwable e) { - final fail = e instanceof InvocationTargetException ? e.targetException : e - - if( fail instanceof NoSuchFileException ) { - return "No such file or directory: $fail.message" - } - if( fail instanceof MissingPropertyException ) { - def name = fail.property ?: LoggerHelper.getDetailMessage(fail) - def result = "No such variable: ${name}" - def details = LoggerHelper.findErrorLine(fail) - if( details ) - result += " -- Check script '${details[0]}' at line: ${details[1]}" - return result - } - def result = fail.message ?: fail.toString() - def details = LoggerHelper.findErrorLine(fail) - if( details ){ - result += " -- Check script '${details[0]}' at line: ${details[1]}" - } - return result - } - /** * Publish output files to a specified target folder * From fb7ce88d1987af05941142c01340fb315681b6a2 Mon Sep 17 00:00:00 2001 From: Ben Sherman Date: Thu, 13 Nov 2025 14:31:39 -0600 Subject: [PATCH 2/2] Add unit tests Signed-off-by: Ben Sherman --- .../processor/TaskErrorFormatter.groovy | 16 +- .../processor/TaskErrorFormatterTest.groovy | 321 ++++++++++++++++++ 2 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy index 8a81e597d6..34f13f7d5d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskErrorFormatter.groovy @@ -51,7 +51,7 @@ class TaskErrorFormatter { // - print the executed command message << "Command executed:\n" - for( final line : error.command.stripIndent(true)?.trim()?.readLines() ) + for( final line : error.command.stripIndent(true).trim().readLines() ) message << " ${line}".toString() // - the exit status @@ -66,7 +66,7 @@ class TaskErrorFormatter { for( final line : lines ) message << " ${stripWorkDir(line, task.workDir)}".toString() - if( task?.workDir ) + if( task.workDir ) message << "\nWork dir:\n ${task.workDirStr}".toString() return message @@ -89,7 +89,7 @@ class TaskErrorFormatter { message << " ${line}".toString() } - if( task?.workDir ) + if( task.workDir ) message << "\nWork dir:\n ${task.workDirStr}".toString() return message @@ -108,10 +108,10 @@ class TaskErrorFormatter { message << formatErrorCause( error ) // task with `script:` block - if( task?.script ) { + if( task.script ) { // -- print the executed command message << "Command executed${task.template ? " [$task.template]": ''}:\n".toString() - for( final line : task.script?.stripIndent(true)?.trim()?.readLines() ) + for( final line : task.script.stripIndent(true).trim().readLines() ) message << " ${line}".toString() // -- the exit status @@ -146,18 +146,18 @@ class TaskErrorFormatter { } // task with `exec:` block - else if( task?.source ) { + else if( task.source ) { message << "Source block:" for( final line : task.source.stripIndent(true).readLines() ) message << " ${line}".toString() } // append work dir if present - if( task?.workDir ) + if( task.workDir ) message << "\nWork dir:\n ${task.workDirStr}".toString() // append container image if present - if( task?.isContainerEnabled() ) + if( task.isContainerEnabled() ) message << "\nContainer:\n ${task.container}".toString() // append tip diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy new file mode 100644 index 0000000000..dd5dfe811f --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskErrorFormatterTest.groovy @@ -0,0 +1,321 @@ +/* + * Copyright 2013-2024, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.processor + +import java.nio.file.Path + +import nextflow.exception.FailedGuardException +import nextflow.exception.ProcessEvalException +import spock.lang.Specification + +/** + * + * @author Ben Sherman + */ +class TaskErrorFormatterTest extends Specification { + + def 'should format command error'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + } + def error = new ProcessEvalException( + 'Command failed', + ' echo "hello"\n exit 1\n', + 'hello\nerror output', + 1 + ) + + when: + def result = formatter.formatCommandError([], error, task).join('\n') + + then: + result.contains('Caused by:') + result.contains('Command executed:') + result.contains('echo "hello"') + result.contains('exit 1') + result.contains('Command exit status:') + result.contains('1') + result.contains('Command output:') + result.contains('hello') + result.contains('error output') + result.contains('Work dir:') + result.contains('/work/dir') + } + + def 'should format command error with empty output'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + } + def error = new ProcessEvalException( + 'Command failed', + 'exit 1', + '', + 1 + ) + + when: + def result = formatter.formatCommandError([], error, task).join('\n') + + then: + result.contains('Command output:') + result.contains('(empty)') + } + + def 'should format command error without work dir'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> null + } + def error = new ProcessEvalException( + 'Command failed', + 'exit 1', + 'output', + 1 + ) + + when: + def result = formatter.formatCommandError([], error, task).join('\n') + + then: + !result.contains('Work dir:') + } + + def 'should format guard error'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + } + def error = new FailedGuardException( + 'Guard failed', + 'when: false', + new IllegalStateException('Invalid condition') + ) + + when: + def result = formatter.formatGuardError([], error, task).join('\n') + + then: + result.contains('Caused by:') + result.contains('When block:') + result.contains('when:') + result.contains('false') + result.contains('Work dir:') + result.contains('/work/dir') + } + + def 'should format guard error without work dir'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> null + } + def error = new FailedGuardException( + 'Guard failed', + 'when: false', + new IllegalStateException('Invalid condition') + ) + + when: + def result = formatter.formatGuardError([], error, task).join('\n') + + then: + !result.contains('Work dir:') + } + + def 'should format task error with script block'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> ' echo "test"\n exit 1\n' + getTemplate() >> null + getExitStatus() >> 1 + dumpStdout(_) >> ['stdout line 1', 'stdout line 2'] + dumpStderr(_) >> ['stderr line 1'] + isContainerEnabled() >> false + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + result.contains('Caused by:') + result.contains('Command executed:') + result.contains('echo "test"') + result.contains('exit 1') + result.contains('Command exit status:') + result.contains('1') + result.contains('Command output:') + result.contains('stdout line 1') + result.contains('stdout line 2') + result.contains('Command error:') + result.contains('stderr line 1') + result.contains('Work dir:') + result.contains('/work/dir') + } + + def 'should format task error with template'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> 'echo "test"' + getTemplate() >> Path.of('template.sh') + getExitStatus() >> 1 + dumpStdout(_) >> ['output'] + dumpStderr(_) >> [] + dumpLogFile(_) >> ['log line 1'] + isContainerEnabled() >> false + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + result.contains('Command executed [template.sh]:') + result.contains('Command log:') + result.contains('log line 1') + } + + def 'should format task error with empty stdout'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> 'exit 1' + getTemplate() >> null + getExitStatus() >> 1 + dumpStdout(_) >> [] + dumpStderr(_) >> ['error'] + isContainerEnabled() >> false + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + result.contains('Command output:') + result.contains('(empty)') + } + + def 'should format task error with exec block'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> null + getSource() >> ' def x = 1\n println x' + isContainerEnabled() >> false + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + result.contains('Caused by:') + result.contains('Source block:') + result.contains('def x = 1') + result.contains('println x') + result.contains('Work dir:') + } + + def 'should format task error with container'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> 'echo test' + getTemplate() >> null + getExitStatus() >> 0 + dumpStdout(_) >> ['output'] + dumpStderr(_) >> [] + isContainerEnabled() >> true + getContainer() >> 'ubuntu:20.04' + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + result.contains('Container:') + result.contains('ubuntu:20.04') + } + + def 'should format task error without work dir'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> null + getScript() >> 'echo test' + getTemplate() >> null + getExitStatus() >> 0 + dumpStdout(_) >> ['output'] + dumpStderr(_) >> [] + isContainerEnabled() >> false + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + !result.contains('Work dir:') + } + + def 'should format task error with MAX_VALUE exit status'() { + given: + def formatter = new TaskErrorFormatter() + def task = Mock(TaskRun) { + getWorkDir() >> Path.of('/work/dir') + getWorkDirStr() >> '/work/dir' + getScript() >> 'echo test' + getTemplate() >> null + getExitStatus() >> Integer.MAX_VALUE + dumpStdout(_) >> [] + dumpStderr(_) >> [] + isContainerEnabled() >> false + } + def error = new RuntimeException('Task failed') + + when: + def result = formatter.formatTaskError([], error, task).join('\n') + + then: + result.contains('Command exit status:') + result.contains('-') + } + +}