This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 23e37b145b issue #7494 : speed up integration tests with single-JVM 
runner and Surefire Report Output (#7506)
23e37b145b is described below

commit 23e37b145bce74fc2e6a55f4e96486155949dca0
Author: Matt Casters <[email protected]>
AuthorDate: Sun Jul 12 16:36:41 2026 +0200

    issue #7494 : speed up integration tests with single-JVM runner and 
Surefire Report Output (#7506)
    
    Run project main-*.hwf suites in one hop-run via a shared runner pipeline 
and
    a new Surefire Report Output transform, cutting cold-start overhead (e.g.
    transforms ~13m to ~4m). WorkflowExecutor keeps input fields on the 
execution
    result stream so test names reach the report. Keep classic per-test JVM mode
    behind HOP_IT_PER_TEST_JVM=true. Also pin 0059 Get Files Rows Count to its
    original four files after an extra .txt broke the wildcard count.
---
 assemblies/plugins/pom.xml                         |   6 +
 integration-tests/scripts/run-project-tests.hpl    | 165 +++++++++
 integration-tests/scripts/run-tests.sh             | 237 ++++++++-----
 integration-tests/scripts/run-workflow.hwf         | 115 ++++++
 .../transforms/0059-get-files-rows-count.hpl       |  25 +-
 plugins/transforms/pom.xml                         |   1 +
 plugins/transforms/surefirereport/pom.xml          |  32 ++
 .../surefirereport/src/assembly/assembly.xml       |  38 ++
 .../surefirereport/SurefireReportOutput.java       | 287 +++++++++++++++
 .../surefirereport/SurefireReportOutputData.java   |  45 +++
 .../surefirereport/SurefireReportOutputDialog.java | 385 +++++++++++++++++++++
 .../surefirereport/SurefireReportOutputMeta.java   | 191 ++++++++++
 .../surefirereport/SurefireReportWriter.java       | 211 +++++++++++
 .../surefirereport/SurefireTestCase.java           |  81 +++++
 .../messages/messages_en_US.properties             |  71 ++++
 .../src/main/resources/surefirereport.svg          | 183 ++++++++++
 .../surefirereport/src/main/resources/version.xml  |  20 ++
 .../SurefireReportOutputMetaTest.java              |  58 ++++
 .../surefirereport/SurefireReportWriterTest.java   | 101 ++++++
 .../workflowexecutor/WorkflowExecutor.java         |   8 +
 .../workflowexecutor/WorkflowExecutorMeta.java     | 142 ++++----
 21 files changed, 2256 insertions(+), 146 deletions(-)

diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index d53fd7e6c5..ef42a43597 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -1525,6 +1525,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-surefirereport</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-transform-switchcase</artifactId>
diff --git a/integration-tests/scripts/run-project-tests.hpl 
b/integration-tests/scripts/run-project-tests.hpl
new file mode 100644
index 0000000000..520c735d45
--- /dev/null
+++ b/integration-tests/scripts/run-project-tests.hpl
@@ -0,0 +1,165 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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.
+
+-->
+<pipeline>
+  <info>
+    <name>run-project-tests</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Run all main-*.hwf integration tests in one JVM and write a 
Surefire report</description>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/07/11 00:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/07/11 00:00:00.000</modified_date>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>get main workflows</from>
+      <to>run-workflow.hwf</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>run-workflow.hwf</from>
+      <to>Surefire Report Output</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>get main workflows</name>
+    <type>GetFileNames</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <doNotFailIfNoFile>N</doNotFailIfNoFile>
+    <dynamic_include_subfolders>N</dynamic_include_subfolders>
+    <file>
+      <file_required>N</file_required>
+      <filemask>main.*\.hwf$</filemask>
+      <include_subfolders>N</include_subfolders>
+      <name>${PROJECT_HOME}</name>
+    </file>
+    <filefield>N</filefield>
+    <filter>
+      <filterfiletype>all_files</filterfiletype>
+    </filter>
+    <isaddresult>N</isaddresult>
+    <limit>0</limit>
+    <raiseAnExceptionIfNoFile>N</raiseAnExceptionIfNoFile>
+    <rownum>N</rownum>
+    <attributes/>
+    <GUI>
+      <xloc>96</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>run-workflow.hwf</name>
+    <type>WorkflowExecutor</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <run_configuration>local</run_configuration>
+    <filename>${Internal.Entry.Current.Folder}/run-workflow.hwf</filename>
+    <group_size>1</group_size>
+    <group_field/>
+    <group_time/>
+    <parameters>
+      <variablemapping>
+        <variable>WORKFLOW_FILE</variable>
+        <field>filename</field>
+        <input/>
+      </variablemapping>
+      <inherit_all_vars>Y</inherit_all_vars>
+    </parameters>
+    <execution_result_target_transform>Surefire Report 
Output</execution_result_target_transform>
+    <execution_time_field>ExecutionTime</execution_time_field>
+    <execution_result_field>ExecutionResult</execution_result_field>
+    <execution_errors_field>ExecutionNrErrors</execution_errors_field>
+    <execution_lines_read_field>ExecutionLinesRead</execution_lines_read_field>
+    
<execution_lines_written_field>ExecutionLinesWritten</execution_lines_written_field>
+    
<execution_lines_input_field>ExecutionLinesInput</execution_lines_input_field>
+    
<execution_lines_output_field>ExecutionLinesOutput</execution_lines_output_field>
+    
<execution_lines_rejected_field>ExecutionLinesRejected</execution_lines_rejected_field>
+    
<execution_lines_updated_field>ExecutionLinesUpdated</execution_lines_updated_field>
+    
<execution_lines_deleted_field>ExecutionLinesDeleted</execution_lines_deleted_field>
+    
<execution_files_retrieved_field>ExecutionFilesRetrieved</execution_files_retrieved_field>
+    
<execution_exit_status_field>ExecutionExitStatus</execution_exit_status_field>
+    <execution_log_text_field>ExecutionLogText</execution_log_text_field>
+    
<execution_log_channelid_field>ExecutionLogChannelId</execution_log_channelid_field>
+    <result_rows_target_transform/>
+    <result_files_target_transform/>
+    <result_files_file_name_field>FileName</result_files_file_name_field>
+    <attributes/>
+    <GUI>
+      <xloc>304</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Surefire Report Output</name>
+    <type>SurefireReportOutput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <filename>${IT_SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml</filename>
+    <suite_name>${PROJECT_NAME}</suite_name>
+    <create_parent_folder>Y</create_parent_folder>
+    <test_name_field>filename</test_name_field>
+    <duration_field>ExecutionTime</duration_field>
+    <duration_in_milliseconds>Y</duration_in_milliseconds>
+    <result_field>ExecutionResult</result_field>
+    <system_out_field>ExecutionLogText</system_out_field>
+    <system_err_field/>
+    <failure_message_field/>
+    <failure_type_field/>
+    <fail_on_test_failure>Y</fail_on_test_failure>
+    <attributes/>
+    <GUI>
+      <xloc>544</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/scripts/run-tests.sh 
b/integration-tests/scripts/run-tests.sh
index 4c465295fa..c2072ca497 100755
--- a/integration-tests/scripts/run-tests.sh
+++ b/integration-tests/scripts/run-tests.sh
@@ -31,6 +31,13 @@ if [ -z "${SUREFIRE_REPORT}" ]; then
   SUREFIRE_REPORT="true"
 fi
 
+# When false/unset (default), run all main-*.hwf tests in a single hop-run JVM 
via
+# run-project-tests.hpl. Set HOP_IT_PER_TEST_JVM=true to restore the classic 
isolation model
+# (one hop-run process per main workflow).
+if [ -z "${HOP_IT_PER_TEST_JVM}" ]; then
+  HOP_IT_PER_TEST_JVM="false"
+fi
+
 # Install any JDBC drivers required by this test set, using the Hop 
driver-download CLI.
 # Driven by HOP_DRIVERS_DOWNLOAD (comma separated driver ids, each optionally 
with a version, e.g.
 # "vertica,mysql:9.2.0"), set per test in the integration-tests-*.yaml compose 
files. Restricted
@@ -127,6 +134,25 @@ mkdir -p "${HOP_AUDIT_FOLDER}"
 # Store current HOP_CONFIG_FOLDER
 TMP_CONFIG_FOLDER="${HOP_CONFIG_FOLDER}"
 
+SUREFIRE_DIR="$(cd "${CURRENT_DIR}/../surefire-reports" && pwd)"
+RUNNER_PIPELINE="${CURRENT_DIR}/run-project-tests.hpl"
+
+# Shared hop-run parameters for both single-JVM and per-test modes
+HOP_RUN_COMMON_ARGS=(
+  -r "local"
+  -e "dev"
+  -p "POSTGRES_HOST=${POSTGRES_HOST}"
+  -p "POSTGRES_DATABASE=${POSTGRES_DATABASE}"
+  -p "POSTGRES_PORT=${POSTGRES_PORT}"
+  -p "POSTGRES_USER=${POSTGRES_USER}"
+  -p "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
+  -p "SSH_TUNNEL_HOST=${SSH_TUNNEL_HOST}"
+  -p "SSH_TUNNEL_PORT=${SSH_TUNNEL_PORT}"
+  -p "SSH_TUNNEL_USER=${SSH_TUNNEL_USER}"
+  -p "SSH_TUNNEL_PASSWORD=${SSH_TUNNEL_PASSWORD}"
+  -p "BOOTSTRAP_SERVERS=${BOOTSTRAP_SERVERS}"
+)
+
 #Loop over project folders
 for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
   #cleanup project testcases
@@ -145,117 +171,158 @@ for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
       skipped_counter=0
       failures_counter=0
 
-      PROJECT_NAME=$(basename $d)
+      PROJECT_NAME=$(basename "$d")
 
       echo ${SPACER}
       echo "Starting Tests in project: ${PROJECT_NAME}"
       echo ${SPACER}
 
-      # Increment timer and set test start time
-      test_counter=$((test_counter + 1))
-
       # Create New Project
       export HOP_CONFIG_FOLDER="$d"
 
-      # Find main hwf files in the test sub-folder
-      #
-      # TODO: add hpl support when result is returned correctly
-      #
-      find $d -name 'main*.hwf' | sort | while read f; do
+      # Prefer single-JVM suite runner when available (unless isolation mode 
is requested)
+      if [ "${HOP_IT_PER_TEST_JVM}" != "true" ] && [ -f "${RUNNER_PIPELINE}" 
]; then
 
-        #cleanup temp files
-        rm -f /tmp/test_output
-        rm -f /tmp/test_output_err
-
-        #set file and test name
-        hop_file="$(realpath $f)"
-        test_name=$(basename $f)
-        test_name=${test_name//'main_'/}
-        test_name=${test_name//'main-'/}
-        test_name=${test_name//'.hwf'/}
-
-        #Starting Test
         echo ${SPACER}
-        echo "Starting Test: $test_name"
+        echo "Running project tests in single JVM via run-project-tests.hpl"
         echo ${SPACER}
 
-        #Start time test
         start_time_test=$SECONDS
 
-        #Run Test
         $HOP_LOCATION/hop-run.sh \
-          -r "local" \
-          -e "dev" \
-          -p POSTGRES_HOST=${POSTGRES_HOST} \
-          -p POSTGRES_DATABASE=${POSTGRES_DATABASE} \
-          -p POSTGRES_PORT=${POSTGRES_PORT} \
-          -p POSTGRES_USER=${POSTGRES_USER} \
-          -p POSTGRES_PASSWORD=${POSTGRES_PASSWORD} \
-          -p SSH_TUNNEL_HOST=${SSH_TUNNEL_HOST} \
-          -p SSH_TUNNEL_PORT=${SSH_TUNNEL_PORT} \
-          -p SSH_TUNNEL_USER=${SSH_TUNNEL_USER} \
-          -p SSH_TUNNEL_PASSWORD=${SSH_TUNNEL_PASSWORD} \
-          -p BOOTSTRAP_SERVERS=${BOOTSTRAP_SERVERS} \
-          -f $hop_file > >(tee /tmp/test_output) 2> >(tee /tmp/test_output_err 
>&1)
-
-        #Capture exit code
-        exit_code=${PIPESTATUS[0]}
+          "${HOP_RUN_COMMON_ARGS[@]}" \
+          -p "PROJECT_NAME=${PROJECT_NAME}" \
+          -p "IT_SUREFIRE_DIR=${SUREFIRE_DIR}" \
+          -f "${RUNNER_PIPELINE}" > >(tee /tmp/test_output) 2> >(tee 
/tmp/test_output_err >&1)
 
-        #Test time duration
+        exit_code=${PIPESTATUS[0]}
         test_duration=$((SECONDS - start_time_test))
+        total_duration=$((SECONDS - start_time))
 
         if (($exit_code >= 1)); then
-          errors_counter=$((errors_counter + 1))
-          failures_counter=$((failures_counter + 1))
-          #Write single line to overview file
-          echo $test_name >>"${CURRENT_DIR}"/../surefire-reports/failed_tests
-          #Create surefire xml failure
-          echo "<testcase name=\"$test_name\" time=\"$test_duration\">" 
>>${TMP_TESTCASES}
-          echo "<failure type=\"$test_name\"></failure>" >>${TMP_TESTCASES}
-          echo "<system-out>" >>${TMP_TESTCASES}
-          echo "<![CDATA[" >>${TMP_TESTCASES}
-          cat /tmp/test_output >>${TMP_TESTCASES}
-          echo "]]>" >>${TMP_TESTCASES}
-          echo "</system-out>" >>${TMP_TESTCASES}
-          echo "<system-err>" >>${TMP_TESTCASES}
-          echo "<![CDATA[" >>${TMP_TESTCASES}
-          cat /tmp/test_output_err >>${TMP_TESTCASES}
-          echo "]]>" >>${TMP_TESTCASES}
-          echo "</system-err>" >>${TMP_TESTCASES}
-          echo "</testcase>" >>${TMP_TESTCASES}
-
+          errors_counter=1
+          failures_counter=1
+          echo "${PROJECT_NAME}" 
>>"${CURRENT_DIR}"/../surefire-reports/failed_tests
         else
-          #Write single line to overview file
-          echo $test_name >>"${CURRENT_DIR}"/../surefire-reports/passed_tests
-          #Create surefire xml success
-          echo "<testcase name=\"$test_name\" time=\"$test_duration\">" 
>>${TMP_TESTCASES}
-          echo "<system-out>" >>${TMP_TESTCASES}
-          echo "<![CDATA[" >>${TMP_TESTCASES}
-          cat /tmp/test_output >>${TMP_TESTCASES}
-          echo "]]>" >>${TMP_TESTCASES}
-          echo "</system-out>" >>${TMP_TESTCASES}
-          echo "</testcase>" >>${TMP_TESTCASES}
+          echo "${PROJECT_NAME}" 
>>"${CURRENT_DIR}"/../surefire-reports/passed_tests
         fi
 
-        #Print results to console
         echo ${SPACER}
-        echo "Test Result"
+        echo "Project suite result"
         echo ${SPACER}
-        echo "Test duration: $test_duration"
-        echo "Test Exit Code: $exit_code"
-
-      done
-
-      total_duration=$((SECONDS - start_time))
-
-      #create final report
-      if [ "${SUREFIRE_REPORT}" = "true" ]; then
+        echo "Duration: $test_duration"
+        echo "Exit Code: $exit_code"
+
+        # Surefire XML is written by the Surefire Report Output transform.
+        # If the transform never ran (startup failure), write a minimal 
failure suite.
+        if [ "${SUREFIRE_REPORT}" = "true" ]; then
+          if [ ! -f "${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml" ]; then
+            echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" 
>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+            echo "<testsuite 
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"; 
xsi:noNamespaceSchemaLocation=\"https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report-3.0.xsd\";
 version=\"3.0\" name=\"${PROJECT_NAME}\" time=\"$total_duration\" tests=\"1\" 
errors=\"1\" skipped=\"0\" failures=\"0\">" 
>>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+            echo "<testcase name=\"suite_startup\" 
time=\"$test_duration\"><failure 
type=\"suite_startup\"></failure><system-out><![CDATA[" 
>>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+            cat /tmp/test_output 
>>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+            echo "]]></system-out><system-err><![CDATA[" 
>>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+            cat /tmp/test_output_err 
>>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+            echo "]]></system-err></testcase></testsuite>" 
>>"${SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml"
+          fi
+        fi
 
-        echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" 
>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
-        echo "<testsuite 
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"; 
xsi:noNamespaceSchemaLocation=\"https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report-3.0.xsd\";
 version=\"3.0\" name=\"${PROJECT_NAME}\" time=\"$total_duration\" 
tests=\"$test_counter\" errors=\"$errors_counter\" skipped=\"$skipped_counter\" 
failures=\"$failures_counter\">" 
>>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
-        cat ${TMP_TESTCASES} 
>>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
-        echo "</testsuite>" 
>>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
+      else
+
+        # Classic path: one hop-run JVM per main-*.hwf
+        #
+        find "$d" -name 'main*.hwf' | sort | while read -r f; do
+
+          #cleanup temp files
+          rm -f /tmp/test_output
+          rm -f /tmp/test_output_err
+
+          #set file and test name
+          hop_file="$(realpath "$f")"
+          test_name=$(basename "$f")
+          test_name=${test_name//'main_'/}
+          test_name=${test_name//'main-'/}
+          test_name=${test_name//'.hwf'/}
+
+          #Starting Test
+          echo ${SPACER}
+          echo "Starting Test: $test_name"
+          echo ${SPACER}
+
+          #Start time test
+          start_time_test=$SECONDS
+
+          #Run Test
+          $HOP_LOCATION/hop-run.sh \
+            "${HOP_RUN_COMMON_ARGS[@]}" \
+            -f "$hop_file" > >(tee /tmp/test_output) 2> >(tee 
/tmp/test_output_err >&1)
+
+          #Capture exit code
+          exit_code=${PIPESTATUS[0]}
+
+          #Test time duration
+          test_duration=$((SECONDS - start_time_test))
+
+          if (($exit_code >= 1)); then
+            #Write single line to overview file
+            echo "$test_name" 
>>"${CURRENT_DIR}"/../surefire-reports/failed_tests
+            #Create surefire xml failure
+            echo "<testcase name=\"$test_name\" time=\"$test_duration\">" 
>>${TMP_TESTCASES}
+            echo "<failure type=\"$test_name\"></failure>" >>${TMP_TESTCASES}
+            echo "<system-out>" >>${TMP_TESTCASES}
+            echo "<![CDATA[" >>${TMP_TESTCASES}
+            cat /tmp/test_output >>${TMP_TESTCASES}
+            echo "]]>" >>${TMP_TESTCASES}
+            echo "</system-out>" >>${TMP_TESTCASES}
+            echo "<system-err>" >>${TMP_TESTCASES}
+            echo "<![CDATA[" >>${TMP_TESTCASES}
+            cat /tmp/test_output_err >>${TMP_TESTCASES}
+            echo "]]>" >>${TMP_TESTCASES}
+            echo "</system-err>" >>${TMP_TESTCASES}
+            echo "</testcase>" >>${TMP_TESTCASES}
+
+          else
+            #Write single line to overview file
+            echo "$test_name" 
>>"${CURRENT_DIR}"/../surefire-reports/passed_tests
+            #Create surefire xml success
+            echo "<testcase name=\"$test_name\" time=\"$test_duration\">" 
>>${TMP_TESTCASES}
+            echo "<system-out>" >>${TMP_TESTCASES}
+            echo "<![CDATA[" >>${TMP_TESTCASES}
+            cat /tmp/test_output >>${TMP_TESTCASES}
+            echo "]]>" >>${TMP_TESTCASES}
+            echo "</system-out>" >>${TMP_TESTCASES}
+            echo "</testcase>" >>${TMP_TESTCASES}
+          fi
+
+          #Print results to console
+          echo ${SPACER}
+          echo "Test Result"
+          echo ${SPACER}
+          echo "Test duration: $test_duration"
+          echo "Test Exit Code: $exit_code"
+
+        done
+
+        total_duration=$((SECONDS - start_time))
+
+        #create final report
+        if [ "${SUREFIRE_REPORT}" = "true" ]; then
+
+          # Count testcases written (subshell-safe)
+          if [ -f "${TMP_TESTCASES}" ]; then
+            test_counter=$(grep -c '<testcase ' "${TMP_TESTCASES}" || true)
+            failures_counter=$(grep -c '<failure ' "${TMP_TESTCASES}" || true)
+            errors_counter=${failures_counter}
+          fi
+
+          echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" 
>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
+          echo "<testsuite 
xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"; 
xsi:noNamespaceSchemaLocation=\"https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report-3.0.xsd\";
 version=\"3.0\" name=\"${PROJECT_NAME}\" time=\"$total_duration\" 
tests=\"$test_counter\" errors=\"$errors_counter\" skipped=\"$skipped_counter\" 
failures=\"$failures_counter\">" 
>>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
+          if [ -f "${TMP_TESTCASES}" ]; then
+            cat ${TMP_TESTCASES} 
>>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
+          fi
+          echo "</testsuite>" 
>>"${CURRENT_DIR}"/../surefire-reports/surefile_${PROJECT_NAME}.xml
 
+        fi
       fi
     fi
   fi
diff --git a/integration-tests/scripts/run-workflow.hwf 
b/integration-tests/scripts/run-workflow.hwf
new file mode 100644
index 0000000000..6d0cce8dc7
--- /dev/null
+++ b/integration-tests/scripts/run-workflow.hwf
@@ -0,0 +1,115 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You 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.
+
+-->
+<workflow>
+  <name>run-workflow</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Run a single main-*.hwf workflow for integration 
tests</description>
+  <extended_description/>
+  <workflow_version/>
+  <workflow_status>0</workflow_status>
+  <created_user>-</created_user>
+  <created_date>2026/07/11 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/07/11 00:00:00.000</modified_date>
+  <parameters>
+    <parameter>
+      <name>WORKFLOW_FILE</name>
+      <default_value/>
+      <description>The filename of the workflow to execute</description>
+    </parameter>
+  </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>144</xloc>
+      <yloc>112</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Run ${WORKFLOW_FILE}</name>
+      <description/>
+      <type>WORKFLOW</type>
+      <attributes/>
+      <run_configuration>local</run_configuration>
+      <filename>${WORKFLOW_FILE}</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <wait_until_finished>Y</wait_until_finished>
+      <create_parent_folder>N</create_parent_folder>
+      <run_configuration>local</run_configuration>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <set_append_logfile>N</set_append_logfile>
+      <parallel>N</parallel>
+      <xloc>304</xloc>
+      <yloc>112</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Abort workflow</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <always_log_rows>N</always_log_rows>
+      <parallel>N</parallel>
+      <xloc>480</xloc>
+      <yloc>112</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>Run ${WORKFLOW_FILE}</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>Run ${WORKFLOW_FILE}</from>
+      <to>Abort workflow</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/transforms/0059-get-files-rows-count.hpl 
b/integration-tests/transforms/0059-get-files-rows-count.hpl
index d162b0a948..0c83ff3edb 100644
--- a/integration-tests/transforms/0059-get-files-rows-count.hpl
+++ b/integration-tests/transforms/0059-get-files-rows-count.hpl
@@ -65,8 +65,29 @@ limitations under the License.
     <filename_Field/>
     <smartCount>Y</smartCount>
     <file>
-      <name>${PROJECT_HOME}/files/</name>
-      <filemask>.*\.txt$</filemask>
+      <name>${PROJECT_HOME}/files/customers-100.txt</name>
+      <filemask/>
+      <exclude_filemask/>
+      <file_required>N</file_required>
+      <include_subfolders>N</include_subfolders>
+    </file>
+    <file>
+      <name>${PROJECT_HOME}/files/sample-file1.txt</name>
+      <filemask/>
+      <exclude_filemask/>
+      <file_required>N</file_required>
+      <include_subfolders>N</include_subfolders>
+    </file>
+    <file>
+      <name>${PROJECT_HOME}/files/sample-file2.txt</name>
+      <filemask/>
+      <exclude_filemask/>
+      <file_required>N</file_required>
+      <include_subfolders>N</include_subfolders>
+    </file>
+    <file>
+      <name>${PROJECT_HOME}/files/sample-file3.txt</name>
+      <filemask/>
       <exclude_filemask/>
       <file_required>N</file_required>
       <include_subfolders>N</include_subfolders>
diff --git a/plugins/transforms/pom.xml b/plugins/transforms/pom.xml
index ab621dd3f2..f2f3e86993 100644
--- a/plugins/transforms/pom.xml
+++ b/plugins/transforms/pom.xml
@@ -149,6 +149,7 @@
         <module>streamschemamerge</module>
         <module>stringcut</module>
         <module>stringoperations</module>
+        <module>surefirereport</module>
         <module>switchcase</module>
         <module>synchronizeaftermerge</module>
         <module>systemdata</module>
diff --git a/plugins/transforms/surefirereport/pom.xml 
b/plugins/transforms/surefirereport/pom.xml
new file mode 100644
index 0000000000..66a0e294f4
--- /dev/null
+++ b/plugins/transforms/surefirereport/pom.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You 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.
+  ~
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.hop</groupId>
+        <artifactId>hop-plugins-transforms</artifactId>
+        <version>2.19.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>hop-transform-surefirereport</artifactId>
+    <packaging>jar</packaging>
+    <name>Hop Plugins Transforms Surefire Report Output</name>
+
+</project>
diff --git a/plugins/transforms/surefirereport/src/assembly/assembly.xml 
b/plugins/transforms/surefirereport/src/assembly/assembly.xml
new file mode 100644
index 0000000000..b517cb04be
--- /dev/null
+++ b/plugins/transforms/surefirereport/src/assembly/assembly.xml
@@ -0,0 +1,38 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You 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.
+  ~
+  -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0";
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+    xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 
http://maven.apache.org/xsd/assembly-2.2.0.xsd";>
+    <id>hop-transform-surefirereport</id>
+    <formats>
+        <format>zip</format>
+    </formats>
+    <baseDirectory>.</baseDirectory>
+    <files>
+        <file>
+            <source>${project.basedir}/src/main/resources/version.xml</source>
+            <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+            <filtered>true</filtered>
+        </file>
+    </files>
+
+    <componentDescriptors>
+        
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+    </componentDescriptors>
+</assembly>
diff --git 
a/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutput.java
 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutput.java
new file mode 100644
index 0000000000..3a44dd68ac
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutput.java
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import 
org.apache.hop.pipeline.transforms.surefirereport.SurefireTestCase.Status;
+
+/**
+ * Accumulates test-result rows and writes a Maven Surefire-compatible XML 
report at end of stream.
+ */
+public class SurefireReportOutput
+    extends BaseTransform<SurefireReportOutputMeta, SurefireReportOutputData> {
+
+  private static final Class<?> PKG = SurefireReportOutputMeta.class;
+
+  public SurefireReportOutput(
+      TransformMeta transformMeta,
+      SurefireReportOutputMeta meta,
+      SurefireReportOutputData data,
+      int copyNr,
+      PipelineMeta pipelineMeta,
+      Pipeline pipeline) {
+    super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+  }
+
+  @Override
+  public boolean processRow() throws HopException {
+    Object[] row = getRow();
+    if (row == null) {
+      writeReport();
+      setOutputDone();
+      return false;
+    }
+
+    if (first) {
+      first = false;
+      data.outputRowMeta = getInputRowMeta().clone();
+      data.suiteStartMs = System.currentTimeMillis();
+      resolveFieldIndexes(getInputRowMeta());
+    }
+
+    SurefireTestCase testCase = buildTestCase(getInputRowMeta(), row);
+    synchronized (data.lock) {
+      data.testCases.add(testCase);
+    }
+
+    putRow(data.outputRowMeta, row);
+    return true;
+  }
+
+  private void resolveFieldIndexes(IRowMeta rowMeta) throws 
HopTransformException {
+    data.indexTestName = requireField(rowMeta, meta.getTestNameField());
+    data.indexDuration = optionalField(rowMeta, meta.getDurationField());
+    data.indexResult = optionalField(rowMeta, meta.getResultField());
+    data.indexSystemOut = optionalField(rowMeta, meta.getSystemOutField());
+    data.indexSystemErr = optionalField(rowMeta, meta.getSystemErrField());
+    data.indexFailureMessage = optionalField(rowMeta, 
meta.getFailureMessageField());
+    data.indexFailureType = optionalField(rowMeta, meta.getFailureTypeField());
+  }
+
+  private int requireField(IRowMeta rowMeta, String fieldName) throws 
HopTransformException {
+    if (Utils.isEmpty(fieldName)) {
+      throw new HopTransformException(
+          BaseMessages.getString(PKG, 
"SurefireReportOutput.Error.TestNameFieldMissing"));
+    }
+    int index = rowMeta.indexOfValue(fieldName);
+    if (index < 0) {
+      throw new HopTransformException(
+          BaseMessages.getString(PKG, 
"SurefireReportOutput.Error.CouldNotFindField", fieldName));
+    }
+    return index;
+  }
+
+  private int optionalField(IRowMeta rowMeta, String fieldName) throws 
HopTransformException {
+    if (Utils.isEmpty(fieldName)) {
+      return -1;
+    }
+    int index = rowMeta.indexOfValue(fieldName);
+    if (index < 0) {
+      throw new HopTransformException(
+          BaseMessages.getString(PKG, 
"SurefireReportOutput.Error.CouldNotFindField", fieldName));
+    }
+    return index;
+  }
+
+  private SurefireTestCase buildTestCase(IRowMeta rowMeta, Object[] row) 
throws HopException {
+    String name = normalizeTestName(ConstString(rowMeta.getString(row, 
data.indexTestName)));
+    if (Utils.isEmpty(name)) {
+      name = "unknown";
+    }
+
+    double durationSeconds = 0.0;
+    if (data.indexDuration >= 0) {
+      IValueMeta vm = rowMeta.getValueMeta(data.indexDuration);
+      Object value = row[data.indexDuration];
+      if (value != null) {
+        double raw = vm.getNumber(value);
+        durationSeconds = meta.isDurationInMilliseconds() ? raw / 1000.0 : raw;
+        if (durationSeconds < 0) {
+          durationSeconds = 0;
+        }
+      }
+    }
+
+    Status status = Status.PASS;
+    if (data.indexResult >= 0) {
+      status = parseStatus(rowMeta, row, data.indexResult);
+    }
+
+    String systemOut =
+        data.indexSystemOut >= 0 ? ConstString(rowMeta.getString(row, 
data.indexSystemOut)) : null;
+    String systemErr =
+        data.indexSystemErr >= 0 ? ConstString(rowMeta.getString(row, 
data.indexSystemErr)) : null;
+    String failureMessage =
+        data.indexFailureMessage >= 0
+            ? ConstString(rowMeta.getString(row, data.indexFailureMessage))
+            : null;
+    String failureType =
+        data.indexFailureType >= 0
+            ? ConstString(rowMeta.getString(row, data.indexFailureType))
+            : null;
+
+    if ((status == Status.FAIL || status == Status.ERROR)
+        && Utils.isEmpty(failureType)
+        && !Utils.isEmpty(name)) {
+      failureType = name;
+    }
+
+    return new SurefireTestCase(
+        name, durationSeconds, status, systemOut, systemErr, failureMessage, 
failureType);
+  }
+
+  static Status parseStatus(IRowMeta rowMeta, Object[] row, int index) throws 
HopException {
+    IValueMeta vm = rowMeta.getValueMeta(index);
+    Object value = row[index];
+    if (value == null) {
+      return Status.FAIL;
+    }
+    if (vm.isBoolean()) {
+      return vm.getBoolean(value) ? Status.PASS : Status.FAIL;
+    }
+    if (vm.isNumeric()) {
+      // Treat as exit-style: 0 = success, non-zero = failure
+      double n = vm.getNumber(value);
+      return n == 0.0 ? Status.PASS : Status.FAIL;
+    }
+    String text = vm.getString(value);
+    if (text == null) {
+      return Status.FAIL;
+    }
+    String normalized = text.trim().toUpperCase();
+    return switch (normalized) {
+      case "Y", "YES", "TRUE", "PASS", "PASSED", "SUCCESS", "OK" -> 
Status.PASS;
+      case "SKIP", "SKIPPED" -> Status.SKIP;
+      case "ERROR" -> Status.ERROR;
+      case "N", "NO", "FALSE", "FAIL", "FAILED", "FAILURE" -> Status.FAIL;
+      default -> Status.FAIL;
+    };
+  }
+
+  private static String ConstString(String value) {
+    return value == null ? "" : value;
+  }
+
+  /**
+   * Normalize workflow filenames to the short test names used by the classic 
IT runner: strip path,
+   * optional {@code main-}/{@code main_} prefix, and {@code .hwf} extension.
+   */
+  static String normalizeTestName(String raw) {
+    if (raw == null || raw.isEmpty()) {
+      return raw;
+    }
+    String name = raw;
+    int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\'));
+    if (slash >= 0 && slash < name.length() - 1) {
+      name = name.substring(slash + 1);
+    }
+    if (name.startsWith("main-") || name.startsWith("main_")) {
+      name = name.substring(5);
+    }
+    if (name.endsWith(".hwf") || name.endsWith(".HWF")) {
+      name = name.substring(0, name.length() - 4);
+    }
+    return name;
+  }
+
+  private void writeReport() throws HopException {
+    String filename = resolve(meta.getFilename());
+    if (Utils.isEmpty(filename)) {
+      throw new HopException(
+          BaseMessages.getString(PKG, 
"SurefireReportOutput.Error.FilenameMissing"));
+    }
+
+    String suiteName = resolve(meta.getSuiteName());
+    if (Utils.isEmpty(suiteName)) {
+      suiteName = "tests";
+    }
+
+    Path reportPath = Paths.get(filename);
+    if (meta.isCreateParentFolder() && reportPath.getParent() != null) {
+      try {
+        java.nio.file.Files.createDirectories(reportPath.getParent());
+      } catch (Exception e) {
+        throw new HopException(
+            BaseMessages.getString(
+                PKG, "SurefireReportOutput.Error.WriteFailed", filename, 
e.getMessage()),
+            e);
+      }
+    }
+
+    int failedOrErrored;
+    try {
+      synchronized (data.lock) {
+        SurefireReportWriter.write(reportPath, suiteName, data.testCases);
+        failedOrErrored = 0;
+        for (SurefireTestCase testCase : data.testCases) {
+          if (testCase.getStatus() == Status.FAIL || testCase.getStatus() == 
Status.ERROR) {
+            failedOrErrored++;
+          }
+        }
+        if (isBasic()) {
+          logBasic(
+              BaseMessages.getString(
+                  PKG,
+                  "SurefireReportOutput.Log.ReportWritten",
+                  Integer.toString(data.testCases.size()),
+                  filename));
+        }
+      }
+    } catch (Exception e) {
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "SurefireReportOutput.Error.WriteFailed", filename, 
e.getMessage()),
+          e);
+    }
+
+    if (meta.isFailOnTestFailure() && failedOrErrored > 0) {
+      logError(
+          BaseMessages.getString(
+              PKG, "SurefireReportOutput.Error.TestsFailed", 
Integer.toString(failedOrErrored)));
+      setErrors(failedOrErrored);
+      stopAll();
+    }
+  }
+
+  @Override
+  public boolean init() {
+    if (super.init()) {
+      if (Utils.isEmpty(meta.getFilename())) {
+        logError(BaseMessages.getString(PKG, 
"SurefireReportOutput.Error.FilenameMissing"));
+        return false;
+      }
+      if (Utils.isEmpty(meta.getTestNameField())) {
+        logError(BaseMessages.getString(PKG, 
"SurefireReportOutput.Error.TestNameFieldMissing"));
+        return false;
+      }
+      return true;
+    }
+    return false;
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputData.java
 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputData.java
new file mode 100644
index 0000000000..df3541debf
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputData.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+
+public class SurefireReportOutputData extends BaseTransformData implements 
ITransformData {
+  public IRowMeta outputRowMeta;
+
+  public int indexTestName = -1;
+  public int indexDuration = -1;
+  public int indexResult = -1;
+  public int indexSystemOut = -1;
+  public int indexSystemErr = -1;
+  public int indexFailureMessage = -1;
+  public int indexFailureType = -1;
+
+  public final List<SurefireTestCase> testCases = new ArrayList<>();
+  public final Object lock = new Object();
+
+  public long suiteStartMs;
+
+  public SurefireReportOutputData() {
+    super();
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputDialog.java
 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputDialog.java
new file mode 100644
index 0000000000..81201594e0
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputDialog.java
@@ -0,0 +1,385 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.apache.hop.ui.pipeline.transform.ComponentSelectionListener;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CCombo;
+import org.eclipse.swt.events.FocusEvent;
+import org.eclipse.swt.events.FocusListener;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.graphics.Cursor;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+public class SurefireReportOutputDialog extends BaseTransformDialog {
+  private static final Class<?> PKG = SurefireReportOutputMeta.class;
+
+  private final SurefireReportOutputMeta input;
+  private boolean gotPreviousFields;
+
+  private TextVar wFilename;
+  private TextVar wSuiteName;
+  private Button wCreateParentFolder;
+  private CCombo wTestNameField;
+  private CCombo wDurationField;
+  private Button wDurationInMilliseconds;
+  private CCombo wResultField;
+  private CCombo wSystemOutField;
+  private CCombo wSystemErrField;
+  private CCombo wFailureMessageField;
+  private CCombo wFailureTypeField;
+  private Button wFailOnTestFailure;
+
+  public SurefireReportOutputDialog(
+      Shell parent,
+      IVariables variables,
+      SurefireReportOutputMeta transformMeta,
+      PipelineMeta pipelineMeta) {
+    super(parent, variables, transformMeta, pipelineMeta);
+    input = transformMeta;
+  }
+
+  @Override
+  public String open() {
+    createShell(BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.Shell.Title"));
+
+    buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+    ModifyListener lsMod = e -> input.setChanged();
+    changed = input.hasChanged();
+
+    Control lastControl = wSpacer;
+
+    // Report filename
+    Label wlFilename = new Label(shell, SWT.RIGHT);
+    wlFilename.setText(BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.Filename.Label"));
+    PropsUi.setLook(wlFilename);
+    FormData fdlFilename = new FormData();
+    fdlFilename.left = new FormAttachment(0, 0);
+    fdlFilename.right = new FormAttachment(middle, -margin);
+    fdlFilename.top = new FormAttachment(lastControl, margin);
+    wlFilename.setLayoutData(fdlFilename);
+
+    wFilename = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    wFilename.setToolTipText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.Filename.Tooltip"));
+    PropsUi.setLook(wFilename);
+    wFilename.addModifyListener(lsMod);
+    FormData fdFilename = new FormData();
+    fdFilename.left = new FormAttachment(middle, 0);
+    fdFilename.top = new FormAttachment(lastControl, margin);
+    fdFilename.right = new FormAttachment(100, 0);
+    wFilename.setLayoutData(fdFilename);
+    lastControl = wFilename;
+
+    // Suite name
+    Label wlSuiteName = new Label(shell, SWT.RIGHT);
+    wlSuiteName.setText(BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.SuiteName.Label"));
+    PropsUi.setLook(wlSuiteName);
+    FormData fdlSuiteName = new FormData();
+    fdlSuiteName.left = new FormAttachment(0, 0);
+    fdlSuiteName.right = new FormAttachment(middle, -margin);
+    fdlSuiteName.top = new FormAttachment(lastControl, margin);
+    wlSuiteName.setLayoutData(fdlSuiteName);
+
+    wSuiteName = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    wSuiteName.setToolTipText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.SuiteName.Tooltip"));
+    PropsUi.setLook(wSuiteName);
+    wSuiteName.addModifyListener(lsMod);
+    FormData fdSuiteName = new FormData();
+    fdSuiteName.left = new FormAttachment(middle, 0);
+    fdSuiteName.top = new FormAttachment(lastControl, margin);
+    fdSuiteName.right = new FormAttachment(100, 0);
+    wSuiteName.setLayoutData(fdSuiteName);
+    lastControl = wSuiteName;
+
+    // Create parent folder
+    Label wlCreateParentFolder = new Label(shell, SWT.RIGHT);
+    wlCreateParentFolder.setText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.CreateParentFolder.Label"));
+    PropsUi.setLook(wlCreateParentFolder);
+    FormData fdlCreateParentFolder = new FormData();
+    fdlCreateParentFolder.left = new FormAttachment(0, 0);
+    fdlCreateParentFolder.right = new FormAttachment(middle, -margin);
+    fdlCreateParentFolder.top = new FormAttachment(lastControl, margin);
+    wlCreateParentFolder.setLayoutData(fdlCreateParentFolder);
+
+    wCreateParentFolder = new Button(shell, SWT.CHECK);
+    PropsUi.setLook(wCreateParentFolder);
+    wCreateParentFolder.setToolTipText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.CreateParentFolder.Tooltip"));
+    FormData fdCreateParentFolder = new FormData();
+    fdCreateParentFolder.left = new FormAttachment(middle, 0);
+    fdCreateParentFolder.top = new FormAttachment(wlCreateParentFolder, 0, 
SWT.CENTER);
+    wCreateParentFolder.setLayoutData(fdCreateParentFolder);
+    wCreateParentFolder.addSelectionListener(new 
ComponentSelectionListener(input));
+    lastControl = wlCreateParentFolder;
+
+    // Fields group
+    Group wFields = new Group(shell, SWT.SHADOW_NONE);
+    PropsUi.setLook(wFields);
+    wFields.setText(BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.Fields.Group.Label"));
+    FormLayout fieldsLayout = new FormLayout();
+    fieldsLayout.marginWidth = 10;
+    fieldsLayout.marginHeight = 10;
+    wFields.setLayout(fieldsLayout);
+    FormData fdFields = new FormData();
+    fdFields.left = new FormAttachment(0, 0);
+    fdFields.top = new FormAttachment(lastControl, margin * 2);
+    fdFields.right = new FormAttachment(100, 0);
+    fdFields.bottom = new FormAttachment(wOk, -margin);
+    wFields.setLayoutData(fdFields);
+
+    Control fieldLast = null;
+
+    fieldLast =
+        addFieldRow(
+            wFields, fieldLast, 
"SurefireReportOutputDialog.TestNameField.Label", lsMod, true);
+    wTestNameField = (CCombo) fieldLast;
+
+    fieldLast =
+        addFieldRow(
+            wFields, fieldLast, 
"SurefireReportOutputDialog.DurationField.Label", lsMod, false);
+    wDurationField = (CCombo) fieldLast;
+
+    Label wlDurationMs = new Label(wFields, SWT.RIGHT);
+    wlDurationMs.setText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.DurationInMilliseconds.Label"));
+    PropsUi.setLook(wlDurationMs);
+    FormData fdlDurationMs = new FormData();
+    fdlDurationMs.left = new FormAttachment(0, 0);
+    fdlDurationMs.right = new FormAttachment(middle, -margin);
+    fdlDurationMs.top = new FormAttachment(fieldLast, margin);
+    wlDurationMs.setLayoutData(fdlDurationMs);
+
+    wDurationInMilliseconds = new Button(wFields, SWT.CHECK);
+    PropsUi.setLook(wDurationInMilliseconds);
+    wDurationInMilliseconds.setToolTipText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.DurationInMilliseconds.Tooltip"));
+    FormData fdDurationMs = new FormData();
+    fdDurationMs.left = new FormAttachment(middle, 0);
+    fdDurationMs.top = new FormAttachment(wlDurationMs, 0, SWT.CENTER);
+    wDurationInMilliseconds.setLayoutData(fdDurationMs);
+    wDurationInMilliseconds.addSelectionListener(new 
ComponentSelectionListener(input));
+    fieldLast = wlDurationMs;
+
+    fieldLast =
+        addFieldRow(
+            wFields, fieldLast, 
"SurefireReportOutputDialog.ResultField.Label", lsMod, false);
+    wResultField = (CCombo) fieldLast;
+    wResultField.setToolTipText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.ResultField.Tooltip"));
+
+    fieldLast =
+        addFieldRow(
+            wFields, fieldLast, 
"SurefireReportOutputDialog.SystemOutField.Label", lsMod, false);
+    wSystemOutField = (CCombo) fieldLast;
+
+    fieldLast =
+        addFieldRow(
+            wFields, fieldLast, 
"SurefireReportOutputDialog.SystemErrField.Label", lsMod, false);
+    wSystemErrField = (CCombo) fieldLast;
+
+    fieldLast =
+        addFieldRow(
+            wFields,
+            fieldLast,
+            "SurefireReportOutputDialog.FailureMessageField.Label",
+            lsMod,
+            false);
+    wFailureMessageField = (CCombo) fieldLast;
+
+    fieldLast =
+        addFieldRow(
+            wFields, fieldLast, 
"SurefireReportOutputDialog.FailureTypeField.Label", lsMod, false);
+    wFailureTypeField = (CCombo) fieldLast;
+
+    Label wlFailOnTestFailure = new Label(wFields, SWT.RIGHT);
+    wlFailOnTestFailure.setText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.FailOnTestFailure.Label"));
+    PropsUi.setLook(wlFailOnTestFailure);
+    FormData fdlFailOnTestFailure = new FormData();
+    fdlFailOnTestFailure.left = new FormAttachment(0, 0);
+    fdlFailOnTestFailure.right = new FormAttachment(middle, -margin);
+    fdlFailOnTestFailure.top = new FormAttachment(fieldLast, margin);
+    wlFailOnTestFailure.setLayoutData(fdlFailOnTestFailure);
+
+    wFailOnTestFailure = new Button(wFields, SWT.CHECK);
+    PropsUi.setLook(wFailOnTestFailure);
+    wFailOnTestFailure.setToolTipText(
+        BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.FailOnTestFailure.Tooltip"));
+    FormData fdFailOnTestFailure = new FormData();
+    fdFailOnTestFailure.left = new FormAttachment(middle, 0);
+    fdFailOnTestFailure.top = new FormAttachment(wlFailOnTestFailure, 0, 
SWT.CENTER);
+    wFailOnTestFailure.setLayoutData(fdFailOnTestFailure);
+    wFailOnTestFailure.addSelectionListener(new 
ComponentSelectionListener(input));
+
+    getData();
+    focusTransformName();
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+    return transformName;
+  }
+
+  private CCombo addFieldRow(
+      Group parent, Control above, String labelKey, ModifyListener lsMod, 
boolean first) {
+    Label wl = new Label(parent, SWT.RIGHT);
+    wl.setText(BaseMessages.getString(PKG, labelKey));
+    PropsUi.setLook(wl);
+    FormData fdl = new FormData();
+    fdl.left = new FormAttachment(0, 0);
+    fdl.right = new FormAttachment(middle, -margin);
+    if (above == null) {
+      fdl.top = new FormAttachment(0, margin);
+    } else {
+      fdl.top = new FormAttachment(above, margin);
+    }
+    wl.setLayoutData(fdl);
+
+    CCombo w = new CCombo(parent, SWT.BORDER | SWT.READ_ONLY);
+    PropsUi.setLook(w);
+    w.addModifyListener(lsMod);
+    FormData fd = new FormData();
+    fd.left = new FormAttachment(middle, 0);
+    if (above == null) {
+      fd.top = new FormAttachment(0, margin);
+    } else {
+      fd.top = new FormAttachment(above, margin);
+    }
+    fd.right = new FormAttachment(100, 0);
+    w.setLayoutData(fd);
+    w.addFocusListener(
+        new FocusListener() {
+          @Override
+          public void focusLost(FocusEvent e) {
+            // no-op
+          }
+
+          @Override
+          public void focusGained(FocusEvent e) {
+            Cursor busy = new Cursor(shell.getDisplay(), SWT.CURSOR_WAIT);
+            shell.setCursor(busy);
+            getFields();
+            shell.setCursor(null);
+            busy.dispose();
+          }
+        });
+    return w;
+  }
+
+  private void getFields() {
+    if (gotPreviousFields) {
+      return;
+    }
+    try {
+      String[] fields = new String[0];
+      IRowMeta r = pipelineMeta.getPrevTransformFields(variables, 
transformName);
+      if (r != null) {
+        fields = r.getFieldNames();
+      }
+      gotPreviousFields = true;
+      setComboValues(wTestNameField, fields);
+      setComboValues(wDurationField, fields);
+      setComboValues(wResultField, fields);
+      setComboValues(wSystemOutField, fields);
+      setComboValues(wSystemErrField, fields);
+      setComboValues(wFailureMessageField, fields);
+      setComboValues(wFailureTypeField, fields);
+    } catch (HopException ke) {
+      new ErrorDialog(
+          shell,
+          BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.FailedToGetFields.DialogTitle"),
+          BaseMessages.getString(PKG, 
"SurefireReportOutputDialog.FailedToGetFields.DialogMessage"),
+          ke);
+    }
+  }
+
+  private void setComboValues(CCombo combo, String[] fields) {
+    if (combo == null) {
+      return;
+    }
+    String current = combo.getText();
+    combo.setItems(fields);
+    if (!Utils.isEmpty(current)) {
+      combo.setText(current);
+    }
+  }
+
+  public void getData() {
+    wTransformName.setText(Const.NVL(transformName, ""));
+    wFilename.setText(Const.NVL(input.getFilename(), ""));
+    wSuiteName.setText(Const.NVL(input.getSuiteName(), ""));
+    wCreateParentFolder.setSelection(input.isCreateParentFolder());
+    wTestNameField.setText(Const.NVL(input.getTestNameField(), ""));
+    wDurationField.setText(Const.NVL(input.getDurationField(), ""));
+    wDurationInMilliseconds.setSelection(input.isDurationInMilliseconds());
+    wResultField.setText(Const.NVL(input.getResultField(), ""));
+    wSystemOutField.setText(Const.NVL(input.getSystemOutField(), ""));
+    wSystemErrField.setText(Const.NVL(input.getSystemErrField(), ""));
+    wFailureMessageField.setText(Const.NVL(input.getFailureMessageField(), 
""));
+    wFailureTypeField.setText(Const.NVL(input.getFailureTypeField(), ""));
+    wFailOnTestFailure.setSelection(input.isFailOnTestFailure());
+    wTransformName.selectAll();
+    wTransformName.setFocus();
+  }
+
+  private void cancel() {
+    transformName = null;
+    input.setChanged(changed);
+    dispose();
+  }
+
+  private void ok() {
+    if (Utils.isEmpty(wTransformName.getText())) {
+      return;
+    }
+    transformName = wTransformName.getText();
+    input.setFilename(wFilename.getText());
+    input.setSuiteName(wSuiteName.getText());
+    input.setCreateParentFolder(wCreateParentFolder.getSelection());
+    input.setTestNameField(wTestNameField.getText());
+    input.setDurationField(wDurationField.getText());
+    input.setDurationInMilliseconds(wDurationInMilliseconds.getSelection());
+    input.setResultField(wResultField.getText());
+    input.setSystemOutField(wSystemOutField.getText());
+    input.setSystemErrField(wSystemErrField.getText());
+    input.setFailureMessageField(wFailureMessageField.getText());
+    input.setFailureTypeField(wFailureTypeField.getText());
+    input.setFailOnTestFailure(wFailOnTestFailure.getSelection());
+    dispose();
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputMeta.java
 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputMeta.java
new file mode 100644
index 0000000000..e16c7c3599
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputMeta.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+@Transform(
+    id = "SurefireReportOutput",
+    image = "surefirereport.svg",
+    name = "i18n::SurefireReportOutput.Name",
+    description = "i18n::SurefireReportOutput.Description",
+    categoryDescription = 
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Output",
+    keywords = "i18n::SurefireReportOutputMeta.keyword",
+    documentationUrl = "/pipeline/transforms/surefirereport.html")
+@Getter
+@Setter
+public class SurefireReportOutputMeta
+    extends BaseTransformMeta<SurefireReportOutput, SurefireReportOutputData> {
+  private static final Class<?> PKG = SurefireReportOutputMeta.class;
+
+  @HopMetadataProperty(
+      key = "filename",
+      injectionKeyDescription = "SurefireReportOutput.Injection.Filename")
+  private String filename;
+
+  @HopMetadataProperty(
+      key = "suite_name",
+      injectionKeyDescription = "SurefireReportOutput.Injection.SuiteName")
+  private String suiteName;
+
+  @HopMetadataProperty(
+      key = "create_parent_folder",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.CreateParentFolder")
+  private boolean createParentFolder;
+
+  @HopMetadataProperty(
+      key = "test_name_field",
+      injectionKeyDescription = "SurefireReportOutput.Injection.TestNameField")
+  private String testNameField;
+
+  @HopMetadataProperty(
+      key = "duration_field",
+      injectionKeyDescription = "SurefireReportOutput.Injection.DurationField")
+  private String durationField;
+
+  @HopMetadataProperty(
+      key = "duration_in_milliseconds",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.DurationInMilliseconds")
+  private boolean durationInMilliseconds;
+
+  @HopMetadataProperty(
+      key = "result_field",
+      injectionKeyDescription = "SurefireReportOutput.Injection.ResultField")
+  private String resultField;
+
+  @HopMetadataProperty(
+      key = "system_out_field",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.SystemOutField")
+  private String systemOutField;
+
+  @HopMetadataProperty(
+      key = "system_err_field",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.SystemErrField")
+  private String systemErrField;
+
+  @HopMetadataProperty(
+      key = "failure_message_field",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.FailureMessageField")
+  private String failureMessageField;
+
+  @HopMetadataProperty(
+      key = "failure_type_field",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.FailureTypeField")
+  private String failureTypeField;
+
+  /** When true, the transform fails the pipeline if any test case failed or 
errored. */
+  @HopMetadataProperty(
+      key = "fail_on_test_failure",
+      injectionKeyDescription = 
"SurefireReportOutput.Injection.FailOnTestFailure")
+  private boolean failOnTestFailure;
+
+  public SurefireReportOutputMeta() {
+    super();
+  }
+
+  @Override
+  public Object clone() {
+    return super.clone();
+  }
+
+  @Override
+  public void setDefault() {
+    filename = "${IT_SUREFIRE_DIR}/surefile_${PROJECT_NAME}.xml";
+    suiteName = "${PROJECT_NAME}";
+    createParentFolder = true;
+    testNameField = "testName";
+    durationField = "ExecutionTime";
+    durationInMilliseconds = true;
+    resultField = "ExecutionResult";
+    systemOutField = "ExecutionLogText";
+    systemErrField = "";
+    failureMessageField = "";
+    failureTypeField = "";
+    failOnTestFailure = true;
+  }
+
+  @Override
+  public void check(
+      List<ICheckResult> remarks,
+      PipelineMeta pipelineMeta,
+      TransformMeta transformMeta,
+      IRowMeta prev,
+      String[] input,
+      String[] output,
+      IRowMeta info,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider) {
+    if (Utils.isEmpty(filename)) {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"SurefireReportOutputMeta.CheckResult.FilenameMissing"),
+              transformMeta));
+    } else {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(PKG, 
"SurefireReportOutputMeta.CheckResult.FilenameOK"),
+              transformMeta));
+    }
+
+    if (Utils.isEmpty(testNameField)) {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(
+                  PKG, 
"SurefireReportOutputMeta.CheckResult.TestNameFieldMissing"),
+              transformMeta));
+    } else {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(PKG, 
"SurefireReportOutputMeta.CheckResult.TestNameFieldOK"),
+              transformMeta));
+    }
+
+    if (input.length > 0) {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_OK,
+              BaseMessages.getString(
+                  PKG, 
"SurefireReportOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms"),
+              transformMeta));
+    } else {
+      remarks.add(
+          new CheckResult(
+              ICheckResult.TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"SurefireReportOutputMeta.CheckResult.NoInputReceived"),
+              transformMeta));
+    }
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportWriter.java
 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportWriter.java
new file mode 100644
index 0000000000..dea912edff
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportWriter.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.List;
+import java.util.Locale;
+import 
org.apache.hop.pipeline.transforms.surefirereport.SurefireTestCase.Status;
+
+/** Writes Maven Surefire 3.0-compatible XML reports. */
+public final class SurefireReportWriter {
+
+  private static final String XSI = 
"http://www.w3.org/2001/XMLSchema-instance";;
+  private static final String XSD =
+      
"https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report-3.0.xsd";;
+
+  private SurefireReportWriter() {}
+
+  public static void write(Path reportFile, String suiteName, 
List<SurefireTestCase> cases)
+      throws IOException {
+    int tests = cases.size();
+    int failures = 0;
+    int errors = 0;
+    int skipped = 0;
+    double totalTime = 0.0;
+    for (SurefireTestCase c : cases) {
+      totalTime += c.getDurationSeconds();
+      switch (c.getStatus()) {
+        case FAIL -> failures++;
+        case ERROR -> errors++;
+        case SKIP -> skipped++;
+        default -> {
+          // PASS
+        }
+      }
+    }
+
+    Path parent = reportFile.getParent();
+    if (parent != null) {
+      Files.createDirectories(parent);
+    }
+
+    Path tmp = reportFile.resolveSibling(reportFile.getFileName().toString() + 
".tmp");
+    try (BufferedWriter out =
+        new BufferedWriter(
+            new OutputStreamWriter(Files.newOutputStream(tmp), 
StandardCharsets.UTF_8))) {
+      out.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
+      out.newLine();
+      out.write("<testsuite xmlns:xsi=\"");
+      out.write(XSI);
+      out.write("\" xsi:noNamespaceSchemaLocation=\"");
+      out.write(XSD);
+      out.write("\" version=\"3.0\" name=\"");
+      out.write(escapeAttribute(suiteName));
+      out.write("\" time=\"");
+      out.write(formatTime(totalTime));
+      out.write("\" tests=\"");
+      out.write(Integer.toString(tests));
+      out.write("\" errors=\"");
+      out.write(Integer.toString(errors));
+      out.write("\" skipped=\"");
+      out.write(Integer.toString(skipped));
+      out.write("\" failures=\"");
+      out.write(Integer.toString(failures));
+      out.write("\">");
+      out.newLine();
+
+      for (SurefireTestCase c : cases) {
+        writeTestCase(out, c);
+      }
+
+      out.write("</testsuite>");
+      out.newLine();
+    }
+
+    try {
+      Files.move(
+          tmp, reportFile, StandardCopyOption.REPLACE_EXISTING, 
StandardCopyOption.ATOMIC_MOVE);
+    } catch (IOException atomicFailed) {
+      Files.move(tmp, reportFile, StandardCopyOption.REPLACE_EXISTING);
+    }
+  }
+
+  private static void writeTestCase(BufferedWriter out, SurefireTestCase c) 
throws IOException {
+    out.write("<testcase name=\"");
+    out.write(escapeAttribute(c.getName()));
+    out.write("\" time=\"");
+    out.write(formatTime(c.getDurationSeconds()));
+    out.write("\">");
+    out.newLine();
+
+    if (c.getStatus() == Status.FAIL || c.getStatus() == Status.ERROR) {
+      String element = c.getStatus() == Status.ERROR ? "error" : "failure";
+      String type =
+          c.getFailureType() != null && !c.getFailureType().isEmpty()
+              ? c.getFailureType()
+              : c.getName();
+      out.write("<");
+      out.write(element);
+      out.write(" type=\"");
+      out.write(escapeAttribute(type));
+      out.write("\"");
+      if (c.getFailureMessage() != null && !c.getFailureMessage().isEmpty()) {
+        out.write(" message=\"");
+        out.write(escapeAttribute(c.getFailureMessage()));
+        out.write("\"");
+      }
+      out.write("></");
+      out.write(element);
+      out.write(">");
+      out.newLine();
+    } else if (c.getStatus() == Status.SKIP) {
+      out.write("<skipped");
+      if (c.getFailureMessage() != null && !c.getFailureMessage().isEmpty()) {
+        out.write(" message=\"");
+        out.write(escapeAttribute(c.getFailureMessage()));
+        out.write("\"");
+      }
+      out.write("/>");
+      out.newLine();
+    }
+
+    if (c.getSystemOut() != null && !c.getSystemOut().isEmpty()) {
+      out.write("<system-out>");
+      out.newLine();
+      writeCdata(out, c.getSystemOut());
+      out.write("</system-out>");
+      out.newLine();
+    }
+
+    if (c.getSystemErr() != null && !c.getSystemErr().isEmpty()) {
+      out.write("<system-err>");
+      out.newLine();
+      writeCdata(out, c.getSystemErr());
+      out.write("</system-err>");
+      out.newLine();
+    }
+
+    out.write("</testcase>");
+    out.newLine();
+  }
+
+  static void writeCdata(BufferedWriter out, String text) throws IOException {
+    // Split CDATA if the text contains the terminator sequence.
+    String remaining = text == null ? "" : text;
+    int idx;
+    out.write("<![CDATA[");
+    while ((idx = remaining.indexOf("]]>")) >= 0) {
+      out.write(remaining.substring(0, idx));
+      out.write("]]]]><![CDATA[>");
+      remaining = remaining.substring(idx + 3);
+    }
+    out.write(remaining);
+    out.write("]]>");
+    out.newLine();
+  }
+
+  static String escapeAttribute(String value) {
+    if (value == null) {
+      return "";
+    }
+    StringBuilder sb = new StringBuilder(value.length() + 16);
+    for (int i = 0; i < value.length(); i++) {
+      char ch = value.charAt(i);
+      switch (ch) {
+        case '&' -> sb.append("&amp;");
+        case '<' -> sb.append("&lt;");
+        case '>' -> sb.append("&gt;");
+        case '"' -> sb.append("&quot;");
+        case '\'' -> sb.append("&apos;");
+        default -> {
+          if (ch < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') {
+            // skip illegal XML 1.0 control characters
+          } else {
+            sb.append(ch);
+          }
+        }
+      }
+    }
+    return sb.toString();
+  }
+
+  static String formatTime(double seconds) {
+    // Surefire commonly uses integer seconds; keep one decimal when needed.
+    if (Math.abs(seconds - Math.rint(seconds)) < 0.0005) {
+      return Long.toString(Math.round(seconds));
+    }
+    return String.format(Locale.US, "%.3f", seconds);
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireTestCase.java
 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireTestCase.java
new file mode 100644
index 0000000000..53c7e8e68f
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireTestCase.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+/** One Surefire testcase row accumulated by {@link SurefireReportOutput}. */
+public class SurefireTestCase {
+  public enum Status {
+    PASS,
+    FAIL,
+    ERROR,
+    SKIP
+  }
+
+  private final String name;
+  private final double durationSeconds;
+  private final Status status;
+  private final String systemOut;
+  private final String systemErr;
+  private final String failureMessage;
+  private final String failureType;
+
+  public SurefireTestCase(
+      String name,
+      double durationSeconds,
+      Status status,
+      String systemOut,
+      String systemErr,
+      String failureMessage,
+      String failureType) {
+    this.name = name;
+    this.durationSeconds = durationSeconds;
+    this.status = status;
+    this.systemOut = systemOut;
+    this.systemErr = systemErr;
+    this.failureMessage = failureMessage;
+    this.failureType = failureType;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public double getDurationSeconds() {
+    return durationSeconds;
+  }
+
+  public Status getStatus() {
+    return status;
+  }
+
+  public String getSystemOut() {
+    return systemOut;
+  }
+
+  public String getSystemErr() {
+    return systemErr;
+  }
+
+  public String getFailureMessage() {
+    return failureMessage;
+  }
+
+  public String getFailureType() {
+    return failureType;
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/main/resources/org/apache/hop/pipeline/transforms/surefirereport/messages/messages_en_US.properties
 
b/plugins/transforms/surefirereport/src/main/resources/org/apache/hop/pipeline/transforms/surefirereport/messages/messages_en_US.properties
new file mode 100644
index 0000000000..e3848041e6
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/main/resources/org/apache/hop/pipeline/transforms/surefirereport/messages/messages_en_US.properties
@@ -0,0 +1,71 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+SurefireReportOutput.Name=Surefire Report Output
+SurefireReportOutput.Description=Write a Maven Surefire/JUnit XML report from 
test result rows
+SurefireReportOutputMeta.keyword=surefire,junit,test,report,xml,jenkins
+
+SurefireReportOutput.Error.FilenameMissing=Report filename is missing\!
+SurefireReportOutput.Error.TestNameFieldMissing=Test name field is missing\!
+SurefireReportOutput.Error.CouldNotFindField=Couldn''t find field ''{0}'' in 
the input row\!
+SurefireReportOutput.Error.WriteFailed=Unable to write Surefire report to 
''{0}'': {1}
+SurefireReportOutput.Error.TestsFailed={0} test case(s) failed or errored
+SurefireReportOutput.Log.ReportWritten=Wrote Surefire report with {0} test 
case(s) to {1}
+
+SurefireReportOutput.Injection.Filename=Report filename (Surefire XML)
+SurefireReportOutput.Injection.SuiteName=Test suite name
+SurefireReportOutput.Injection.CreateParentFolder=Create parent folder (Y/N)
+SurefireReportOutput.Injection.TestNameField=Field containing the test name
+SurefireReportOutput.Injection.DurationField=Field containing the test duration
+SurefireReportOutput.Injection.DurationInMilliseconds=Duration field is in 
milliseconds (Y/N)
+SurefireReportOutput.Injection.ResultField=Field containing the test result 
(Boolean or status)
+SurefireReportOutput.Injection.SystemOutField=Field containing system-out log 
text
+SurefireReportOutput.Injection.SystemErrField=Field containing system-err log 
text
+SurefireReportOutput.Injection.FailureMessageField=Field containing the 
failure message
+SurefireReportOutput.Injection.FailureTypeField=Field containing the failure 
type
+SurefireReportOutput.Injection.FailOnTestFailure=Fail the pipeline when any 
test fails (Y/N)
+SurefireReportOutputDialog.FailOnTestFailure.Label=Fail pipeline on test 
failure
+SurefireReportOutputDialog.FailOnTestFailure.Tooltip=When enabled, the 
pipeline fails after writing the report if any test case failed or errored
+
+SurefireReportOutputMeta.CheckResult.FilenameMissing=Report filename is 
missing\!
+SurefireReportOutputMeta.CheckResult.FilenameOK=Report filename is specified
+SurefireReportOutputMeta.CheckResult.TestNameFieldMissing=Test name field is 
missing\!
+SurefireReportOutputMeta.CheckResult.TestNameFieldOK=Test name field is 
specified
+SurefireReportOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms=Transform
 is receiving info from other transforms.
+SurefireReportOutputMeta.CheckResult.NoInputReceived=No input received from 
other transforms\!
+
+SurefireReportOutputDialog.Shell.Title=Surefire Report Output
+SurefireReportOutputDialog.TransformName.Label=Transform name
+SurefireReportOutputDialog.Filename.Label=Report filename
+SurefireReportOutputDialog.Filename.Tooltip=Path of the Surefire XML report to 
write (variables allowed)
+SurefireReportOutputDialog.SuiteName.Label=Test suite name
+SurefireReportOutputDialog.SuiteName.Tooltip=Name attribute of the testsuite 
element (variables allowed)
+SurefireReportOutputDialog.CreateParentFolder.Label=Create parent folder
+SurefireReportOutputDialog.CreateParentFolder.Tooltip=Create the parent folder 
of the report file if it does not exist
+SurefireReportOutputDialog.Fields.Group.Label=Input fields
+SurefireReportOutputDialog.TestNameField.Label=Test name field
+SurefireReportOutputDialog.DurationField.Label=Duration field
+SurefireReportOutputDialog.DurationInMilliseconds.Label=Duration is in 
milliseconds
+SurefireReportOutputDialog.DurationInMilliseconds.Tooltip=When checked, 
duration values are converted from milliseconds to seconds for Surefire
+SurefireReportOutputDialog.ResultField.Label=Result field
+SurefireReportOutputDialog.ResultField.Tooltip=Boolean true/false, or 
PASS/FAIL/ERROR/SKIP (or Y/N)
+SurefireReportOutputDialog.SystemOutField.Label=System-out field
+SurefireReportOutputDialog.SystemErrField.Label=System-err field
+SurefireReportOutputDialog.FailureMessageField.Label=Failure message field
+SurefireReportOutputDialog.FailureTypeField.Label=Failure type field
+SurefireReportOutputDialog.FailedToGetFields.DialogTitle=Get fields failed
+SurefireReportOutputDialog.FailedToGetFields.DialogMessage=Unable to get 
fields from previous transforms because of an error
diff --git 
a/plugins/transforms/surefirereport/src/main/resources/surefirereport.svg 
b/plugins/transforms/surefirereport/src/main/resources/surefirereport.svg
new file mode 100644
index 0000000000..6b7df2f1b2
--- /dev/null
+++ b/plugins/transforms/surefirereport/src/main/resources/surefirereport.svg
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 17.0.0, SVG Export Plug-In . SVG Version: 
6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" 
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd";>
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg"; x="0px" y="0px"
+     width="42px" height="42px" viewBox="0 0 42 42" enable-background="new 0 0 
42 42">
+    <g>
+        <polygon fill="#0E3A5A" points="5.534,11.804 11.924,11.802 
11.924,5.475 10.023,5.475 10.023,8.559 4.798,3.334 3.454,4.678
+               8.68,9.903 5.534,9.903  "/>
+        <path fill="#C9E8FB" 
d="M21.367,21.796c-0.624,0-0.827,0.237-0.827,0.732v2.048c0,0.496,0.203,0.732,0.827,0.732
+               
s0.827-0.236,0.827-0.732v-2.048C22.195,22.033,21.991,21.796,21.367,21.796z"/>
+        <path fill="#C9E8FB" 
d="M23.521,27.196c-0.624,0-0.827,0.237-0.827,0.733v2.047c0,0.496,0.203,0.732,0.827,0.732
+               
c0.624,0,0.827-0.236,0.827-0.732v-2.047C24.347,27.433,24.144,27.196,23.521,27.196z"/>
+        <path fill="#C9E8FB" 
d="M21.367,14.507c0.624,0,0.827-0.237,0.827-0.732v-2.048c0-0.495-0.203-0.732-0.827-0.732
+               
s-0.827,0.237-0.827,0.732v2.048C20.54,14.269,20.743,14.507,21.367,14.507z"/>
+        <path fill="#C9E8FB" 
d="M15.378,14.507c0.624,0,0.827-0.237,0.827-0.732v-2.048c0-0.069-0.015-0.122-0.023-0.181l-1.631,1.631
+               v0.598C14.55,14.269,14.754,14.507,15.378,14.507z"/>
+        <path fill="#C9E8FB" 
d="M15.378,25.308c0.624,0,0.827-0.236,0.827-0.732v-2.048c0-0.495-0.203-0.732-0.827-0.732
+               
s-0.828,0.237-0.828,0.732v2.048C14.55,25.072,14.754,25.308,15.378,25.308z"/>
+        <path fill="#C9E8FB" 
d="M17.531,19.908c0.624,0,0.827-0.238,0.827-0.733v-2.048c0-0.495-0.203-0.731-0.827-0.731
+               
s-0.828,0.236-0.828,0.731v2.048C16.703,19.67,16.907,19.908,17.531,19.908z"/>
+        <path fill="#C9E8FB" 
d="M23.521,19.908c0.624,0,0.827-0.238,0.827-0.733v-2.048c0-0.495-0.203-0.731-0.827-0.731
+               
c-0.624,0-0.827,0.236-0.827,0.731v2.048C22.694,19.67,22.897,19.908,23.521,19.908z"/>
+        <path fill="#C9E8FB" 
d="M30.037,33.222L29.979,13.07h-4.257V8.812h-6.808l-2.266,2.266c0.084,0.17,0.126,0.367,0.126,0.586v2.171
+               
c0,0.678-0.346,1.173-1.396,1.173c-1.052,0-1.397-0.495-1.397-1.173v-0.089l-1.637,1.637v17.839H30.037z
 M18.928,30.038
+               
c0,0.679-0.346,1.173-1.397,1.173c-1.051,0-1.397-0.494-1.397-1.173v-2.17c0-0.678,0.346-1.174,1.397-1.174
+               c1.051,0,1.397,0.496,1.397,1.174V30.038z 
M18.204,21.863c-0.11,0.057-0.306,0.14-0.345,0.14c-0.055,0-0.085-0.017-0.091-0.051
+               
c-0.004-0.02-0.008-0.04-0.012-0.061c-0.014-0.067-0.028-0.137-0.028-0.199c0-0.06,0.036-0.098,0.134-0.141
+               
c0.185-0.083,0.569-0.258,0.815-0.258c0.141,0,0.162,0.107,0.162,0.173v4.162c0,0.132-0.043,0.182-0.162,0.182h-0.18
+               c-0.177,0-0.229-0.024-0.229-0.182v-3.797L18.204,21.863z 
M24.918,30.038c0,0.679-0.346,1.173-1.397,1.173
+               
s-1.397-0.494-1.397-1.173v-2.17c0-0.678,0.346-1.174,1.397-1.174s1.397,0.496,1.397,1.174V30.038z
 M21.367,25.81
+               
c-1.051,0-1.397-0.495-1.397-1.173v-2.17c0-0.679,0.346-1.174,1.397-1.174s1.397,0.495,1.397,1.174v2.17
+               C22.764,25.315,22.419,25.81,21.367,25.81z 
M24.194,21.863c-0.111,0.057-0.305,0.14-0.346,0.14c-0.055,0-0.085-0.017-0.091-0.051
+               
c-0.003-0.02-0.008-0.04-0.012-0.061c-0.014-0.067-0.029-0.137-0.029-0.199c0-0.06,0.037-0.098,0.134-0.141
+               
c0.184-0.083,0.568-0.258,0.814-0.258c0.141,0,0.162,0.107,0.162,0.173v4.162c0,0.132-0.043,0.182-0.162,0.182h-0.179
+               c-0.178,0-0.229-0.024-0.229-0.182v-3.797L24.194,21.863z 
M23.851,10.751c0.184-0.084,0.568-0.258,0.814-0.258
+               
c0.141,0,0.162,0.107,0.162,0.171v4.162c0,0.134-0.043,0.183-0.162,0.183h-0.179c-0.178,0-0.229-0.023-0.229-0.183V11.03
+               
l-0.063,0.032c-0.111,0.056-0.305,0.14-0.346,0.14c-0.055,0-0.085-0.018-0.091-0.052c-0.003-0.019-0.008-0.04-0.012-0.06
+               
c-0.014-0.066-0.029-0.137-0.029-0.199C23.717,10.831,23.754,10.794,23.851,10.751z
 M24.918,17.065v2.171
+               
c0,0.678-0.346,1.173-1.397,1.173s-1.397-0.495-1.397-1.173v-2.171c0-0.678,0.346-1.172,1.397-1.172S24.918,16.387,24.918,17.065z
+                
M19.97,11.665c0-0.678,0.346-1.172,1.397-1.172s1.397,0.494,1.397,1.172v2.171c0,0.678-0.346,1.173-1.397,1.173
+               s-1.397-0.495-1.397-1.173V11.665z 
M20.014,16.151c0.185-0.084,0.569-0.258,0.815-0.258c0.141,0,0.162,0.107,0.162,0.172v4.162
+               
c0,0.133-0.044,0.182-0.162,0.182h-0.18c-0.177,0-0.229-0.023-0.229-0.182V16.43l-0.063,0.032c-0.111,0.057-0.306,0.14-0.346,0.14
+               
c-0.055,0-0.085-0.018-0.09-0.052c-0.004-0.018-0.008-0.04-0.012-0.06c-0.014-0.067-0.028-0.137-0.028-0.198
+               C19.881,16.231,19.917,16.194,20.014,16.151z 
M20.014,26.952c0.185-0.083,0.569-0.258,0.815-0.258c0.141,0,0.162,0.107,0.162,0.173
+               
v4.162c0,0.132-0.044,0.182-0.162,0.182h-0.18c-0.177,0-0.229-0.023-0.229-0.182v-3.796l-0.063,0.031
+               
c-0.111,0.056-0.306,0.139-0.346,0.139c-0.055,0-0.085-0.016-0.09-0.051c-0.004-0.019-0.008-0.04-0.012-0.061
+               
c-0.014-0.067-0.028-0.137-0.028-0.199C19.881,27.033,19.917,26.995,20.014,26.952z
 M17.861,10.751
+               
c0.185-0.084,0.569-0.258,0.815-0.258c0.141,0,0.162,0.107,0.162,0.171v4.162c0,0.134-0.043,0.183-0.162,0.183h-0.18
+               
c-0.177,0-0.229-0.023-0.229-0.183V11.03l-0.063,0.032c-0.11,0.056-0.306,0.14-0.345,0.14c-0.055,0-0.085-0.018-0.091-0.052
+               
c-0.004-0.019-0.008-0.04-0.012-0.06c-0.014-0.066-0.028-0.137-0.028-0.199C17.727,10.831,17.763,10.794,17.861,10.751z
+                
M16.134,17.065c0-0.678,0.346-1.172,1.397-1.172c1.051,0,1.397,0.494,1.397,1.172v2.171c0,0.678-0.346,1.173-1.397,1.173
+               c-1.051,0-1.397-0.495-1.397-1.173V17.065z 
M16.774,22.467v2.17c0,0.678-0.346,1.173-1.396,1.173c-1.052,0-1.397-0.495-1.397-1.173
+               
v-2.17c0-0.679,0.346-1.174,1.397-1.174C16.428,21.294,16.774,21.789,16.774,22.467z
 M14.024,16.151
+               
c0.185-0.084,0.568-0.258,0.815-0.258c0.141,0,0.162,0.107,0.162,0.172v4.162c0,0.133-0.044,0.182-0.162,0.182h-0.18
+               
c-0.177,0-0.229-0.023-0.229-0.182V16.43l-0.064,0.032c-0.111,0.057-0.305,0.14-0.345,0.14c-0.055,0-0.085-0.018-0.091-0.052
+               
c-0.003-0.018-0.008-0.04-0.012-0.06c-0.014-0.067-0.028-0.137-0.028-0.198C13.89,16.231,13.927,16.194,14.024,16.151z
+                
M14.024,26.952c0.185-0.083,0.568-0.258,0.815-0.258c0.141,0,0.162,0.107,0.162,0.173v4.162c0,0.132-0.044,0.182-0.162,0.182
+               
h-0.18c-0.177,0-0.229-0.023-0.229-0.182v-3.796l-0.064,0.031c-0.111,0.056-0.305,0.139-0.345,0.139
+               
c-0.055,0-0.085-0.016-0.091-0.051c-0.003-0.019-0.008-0.04-0.012-0.061c-0.014-0.067-0.028-0.137-0.028-0.199
+               C13.89,27.033,13.927,26.995,14.024,26.952z"/>
+        <path fill="#C9E8FB" 
d="M17.531,27.196c-0.624,0-0.828,0.237-0.828,0.733v2.047c0,0.496,0.204,0.732,0.828,0.732
+               
s0.827-0.236,0.827-0.732v-2.047C18.358,27.433,18.155,27.196,17.531,27.196z"/>
+        <polygon fill="#0E3A5A" points="31.679,13.08 29.97,11.371 
27.422,11.371 27.422,8.823 25.712,7.113 20.614,7.113 18.914,8.812
+               25.722,8.812 25.722,13.07 29.979,13.07 30.037,33.222 
12.343,33.222 12.343,15.383 10.643,17.084 10.643,34.922 31.742,34.922      
+               "/>
+        <path fill="#C9E8FB" 
d="M15.378,15.008c1.051,0,1.396-0.495,1.396-1.173v-2.171c0-0.219-0.042-0.416-0.126-0.586l-0.467,0.467
+               
c0.009,0.059,0.023,0.112,0.023,0.181v2.048c0,0.495-0.203,0.732-0.827,0.732s-0.828-0.237-0.828-0.732v-0.598l-0.57,0.57v0.089
+               C13.98,14.513,14.326,15.008,15.378,15.008z"/>
+        <path fill="#0E3A5A" 
d="M15.378,15.008c1.051,0,1.396-0.495,1.396-1.173v-2.171c0-0.219-0.042-0.416-0.126-0.586l-0.467,0.467
+               
c0.009,0.059,0.023,0.112,0.023,0.181v2.048c0,0.495-0.203,0.732-0.827,0.732s-0.828-0.237-0.828-0.732v-0.598l-0.57,0.57v0.089
+               C13.98,14.513,14.326,15.008,15.378,15.008z"/>
+        <path fill="#C9E8FB" 
d="M17.768,11.15c0.005,0.034,0.036,0.052,0.091,0.052c0.04,0,0.235-0.084,0.345-0.14l0.063-0.032v3.796
+               
c0,0.159,0.052,0.183,0.229,0.183h0.18c0.119,0,0.162-0.049,0.162-0.183v-4.162c0-0.064-0.021-0.171-0.162-0.171
+               
c-0.246,0-0.63,0.174-0.815,0.258c-0.098,0.043-0.134,0.08-0.134,0.14c0,0.063,0.014,0.133,0.028,0.199
+               C17.76,11.11,17.764,11.131,17.768,11.15z"/>
+        <path fill="#0E3A5A" 
d="M17.768,11.15c0.005,0.034,0.036,0.052,0.091,0.052c0.04,0,0.235-0.084,0.345-0.14l0.063-0.032v3.796
+               
c0,0.159,0.052,0.183,0.229,0.183h0.18c0.119,0,0.162-0.049,0.162-0.183v-4.162c0-0.064-0.021-0.171-0.162-0.171
+               
c-0.246,0-0.63,0.174-0.815,0.258c-0.098,0.043-0.134,0.08-0.134,0.14c0,0.063,0.014,0.133,0.028,0.199
+               C17.76,11.11,17.764,11.131,17.768,11.15z"/>
+        <path fill="#C9E8FB" 
d="M21.367,15.008c1.051,0,1.397-0.495,1.397-1.173v-2.171c0-0.678-0.346-1.172-1.397-1.172
+               
s-1.397,0.494-1.397,1.172v2.171C19.97,14.513,20.316,15.008,21.367,15.008z 
M20.54,11.726c0-0.495,0.203-0.732,0.827-0.732
+               
s0.827,0.237,0.827,0.732v2.048c0,0.495-0.203,0.732-0.827,0.732s-0.827-0.237-0.827-0.732V11.726z"/>
+        <path fill="#0E3A5A" 
d="M21.367,15.008c1.051,0,1.397-0.495,1.397-1.173v-2.171c0-0.678-0.346-1.172-1.397-1.172
+               
s-1.397,0.494-1.397,1.172v2.171C19.97,14.513,20.316,15.008,21.367,15.008z 
M20.54,11.726c0-0.495,0.203-0.732,0.827-0.732
+               
s0.827,0.237,0.827,0.732v2.048c0,0.495-0.203,0.732-0.827,0.732s-0.827-0.237-0.827-0.732V11.726z"/>
+        <path fill="#C9E8FB" 
d="M23.758,11.15c0.005,0.034,0.036,0.052,0.091,0.052c0.041,0,0.234-0.084,0.346-0.14l0.063-0.032v3.796
+               
c0,0.159,0.051,0.183,0.229,0.183h0.179c0.119,0,0.162-0.049,0.162-0.183v-4.162c0-0.064-0.021-0.171-0.162-0.171
+               
c-0.246,0-0.63,0.174-0.814,0.258c-0.098,0.043-0.134,0.08-0.134,0.14c0,0.063,0.015,0.133,0.029,0.199
+               C23.75,11.11,23.755,11.131,23.758,11.15z"/>
+        <path fill="#0E3A5A" 
d="M23.758,11.15c0.005,0.034,0.036,0.052,0.091,0.052c0.041,0,0.234-0.084,0.346-0.14l0.063-0.032v3.796
+               
c0,0.159,0.051,0.183,0.229,0.183h0.179c0.119,0,0.162-0.049,0.162-0.183v-4.162c0-0.064-0.021-0.171-0.162-0.171
+               
c-0.246,0-0.63,0.174-0.814,0.258c-0.098,0.043-0.134,0.08-0.134,0.14c0,0.063,0.015,0.133,0.029,0.199
+               C23.75,11.11,23.755,11.131,23.758,11.15z"/>
+        <path fill="#C9E8FB" 
d="M13.98,22.467v2.17c0,0.678,0.346,1.173,1.397,1.173c1.051,0,1.396-0.495,1.396-1.173v-2.17
+               
c0-0.679-0.346-1.174-1.396-1.174C14.326,21.294,13.98,21.789,13.98,22.467z 
M14.55,22.528c0-0.495,0.204-0.732,0.828-0.732
+               
s0.827,0.237,0.827,0.732v2.048c0,0.496-0.203,0.732-0.827,0.732s-0.828-0.236-0.828-0.732V22.528z"/>
+        <path fill="#0E3A5A" 
d="M13.98,22.467v2.17c0,0.678,0.346,1.173,1.397,1.173c1.051,0,1.396-0.495,1.396-1.173v-2.17
+               
c0-0.679-0.346-1.174-1.396-1.174C14.326,21.294,13.98,21.789,13.98,22.467z 
M14.55,22.528c0-0.495,0.204-0.732,0.828-0.732
+               
s0.827,0.237,0.827,0.732v2.048c0,0.496-0.203,0.732-0.827,0.732s-0.828-0.236-0.828-0.732V22.528z"/>
+        <path fill="#C9E8FB" 
d="M18.496,25.81h0.18c0.119,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.246,0-0.63,0.175-0.815,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.014,0.132,0.028,0.199
+               
c0.004,0.021,0.008,0.041,0.012,0.061c0.005,0.034,0.036,0.051,0.091,0.051c0.04,0,0.235-0.083,0.345-0.14l0.063-0.031v3.797
+               C18.267,25.786,18.319,25.81,18.496,25.81z"/>
+        <path fill="#0E3A5A" 
d="M18.496,25.81h0.18c0.119,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.246,0-0.63,0.175-0.815,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.014,0.132,0.028,0.199
+               
c0.004,0.021,0.008,0.041,0.012,0.061c0.005,0.034,0.036,0.051,0.091,0.051c0.04,0,0.235-0.083,0.345-0.14l0.063-0.031v3.797
+               C18.267,25.786,18.319,25.81,18.496,25.81z"/>
+        <path fill="#C9E8FB" 
d="M22.764,24.637v-2.17c0-0.679-0.346-1.174-1.397-1.174s-1.397,0.495-1.397,1.174v2.17
+               c0,0.678,0.346,1.173,1.397,1.173S22.764,25.315,22.764,24.637z 
M22.195,24.576c0,0.496-0.203,0.732-0.827,0.732
+               
s-0.827-0.236-0.827-0.732v-2.048c0-0.495,0.203-0.732,0.827-0.732s0.827,0.237,0.827,0.732V24.576z"/>
+        <path fill="#0E3A5A" 
d="M22.764,24.637v-2.17c0-0.679-0.346-1.174-1.397-1.174s-1.397,0.495-1.397,1.174v2.17
+               c0,0.678,0.346,1.173,1.397,1.173S22.764,25.315,22.764,24.637z 
M22.195,24.576c0,0.496-0.203,0.732-0.827,0.732
+               
s-0.827-0.236-0.827-0.732v-2.048c0-0.495,0.203-0.732,0.827-0.732s0.827,0.237,0.827,0.732V24.576z"/>
+        <path fill="#C9E8FB" 
d="M24.487,25.81h0.179c0.119,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.246,0-0.63,0.175-0.814,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.015,0.132,0.029,0.199
+               
c0.004,0.021,0.009,0.041,0.012,0.061c0.005,0.034,0.036,0.051,0.091,0.051c0.041,0,0.234-0.083,0.346-0.14l0.063-0.031v3.797
+               C24.258,25.786,24.309,25.81,24.487,25.81z"/>
+        <path fill="#0E3A5A" 
d="M24.487,25.81h0.179c0.119,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.246,0-0.63,0.175-0.814,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.015,0.132,0.029,0.199
+               
c0.004,0.021,0.009,0.041,0.012,0.061c0.005,0.034,0.036,0.051,0.091,0.051c0.041,0,0.234-0.083,0.346-0.14l0.063-0.031v3.797
+               C24.258,25.786,24.309,25.81,24.487,25.81z"/>
+        <path fill="#C9E8FB" 
d="M13.931,16.55c0.005,0.034,0.036,0.052,0.091,0.052c0.04,0,0.234-0.083,0.345-0.14l0.064-0.032v3.797
+               
c0,0.158,0.051,0.182,0.229,0.182h0.18c0.118,0,0.162-0.049,0.162-0.182v-4.162c0-0.065-0.021-0.172-0.162-0.172
+               
c-0.247,0-0.631,0.174-0.815,0.258c-0.098,0.043-0.134,0.08-0.134,0.141c0,0.062,0.015,0.131,0.028,0.198
+               C13.923,16.51,13.928,16.532,13.931,16.55z"/>
+        <path fill="#0E3A5A" 
d="M13.931,16.55c0.005,0.034,0.036,0.052,0.091,0.052c0.04,0,0.234-0.083,0.345-0.14l0.064-0.032v3.797
+               
c0,0.158,0.051,0.182,0.229,0.182h0.18c0.118,0,0.162-0.049,0.162-0.182v-4.162c0-0.065-0.021-0.172-0.162-0.172
+               
c-0.247,0-0.631,0.174-0.815,0.258c-0.098,0.043-0.134,0.08-0.134,0.141c0,0.062,0.015,0.131,0.028,0.198
+               C13.923,16.51,13.928,16.532,13.931,16.55z"/>
+        <path fill="#C9E8FB" 
d="M17.531,20.409c1.051,0,1.397-0.495,1.397-1.173v-2.171c0-0.678-0.346-1.172-1.397-1.172
+               
c-1.051,0-1.397,0.494-1.397,1.172v2.171C16.134,19.914,16.479,20.409,17.531,20.409z
 M16.703,17.127
+               
c0-0.495,0.204-0.731,0.828-0.731s0.827,0.236,0.827,0.731v2.048c0,0.495-0.203,0.733-0.827,0.733s-0.828-0.238-0.828-0.733V17.127
+               z"/>
+        <path fill="#0E3A5A" 
d="M17.531,20.409c1.051,0,1.397-0.495,1.397-1.173v-2.171c0-0.678-0.346-1.172-1.397-1.172
+               
c-1.051,0-1.397,0.494-1.397,1.172v2.171C16.134,19.914,16.479,20.409,17.531,20.409z
 M16.703,17.127
+               
c0-0.495,0.204-0.731,0.828-0.731s0.827,0.236,0.827,0.731v2.048c0,0.495-0.203,0.733-0.827,0.733s-0.828-0.238-0.828-0.733V17.127
+               z"/>
+        <path fill="#C9E8FB" 
d="M19.921,16.55c0.005,0.034,0.036,0.052,0.09,0.052c0.04,0,0.235-0.083,0.346-0.14l0.063-0.032v3.797
+               
c0,0.158,0.052,0.182,0.229,0.182h0.18c0.118,0,0.162-0.049,0.162-0.182v-4.162c0-0.065-0.021-0.172-0.162-0.172
+               
c-0.246,0-0.63,0.174-0.815,0.258c-0.098,0.043-0.134,0.08-0.134,0.141c0,0.062,0.014,0.131,0.028,0.198
+               C19.913,16.51,19.917,16.532,19.921,16.55z"/>
+        <path fill="#0E3A5A" 
d="M19.921,16.55c0.005,0.034,0.036,0.052,0.09,0.052c0.04,0,0.235-0.083,0.346-0.14l0.063-0.032v3.797
+               
c0,0.158,0.052,0.182,0.229,0.182h0.18c0.118,0,0.162-0.049,0.162-0.182v-4.162c0-0.065-0.021-0.172-0.162-0.172
+               
c-0.246,0-0.63,0.174-0.815,0.258c-0.098,0.043-0.134,0.08-0.134,0.141c0,0.062,0.014,0.131,0.028,0.198
+               C19.913,16.51,19.917,16.532,19.921,16.55z"/>
+        <path fill="#C9E8FB" 
d="M22.123,17.065v2.171c0,0.678,0.346,1.173,1.397,1.173s1.397-0.495,1.397-1.173v-2.171
+               c0-0.678-0.346-1.172-1.397-1.172S22.123,16.387,22.123,17.065z 
M22.694,17.127c0-0.495,0.203-0.731,0.827-0.731
+               
c0.624,0,0.827,0.236,0.827,0.731v2.048c0,0.495-0.203,0.733-0.827,0.733c-0.624,0-0.827-0.238-0.827-0.733V17.127z"/>
+        <path fill="#0E3A5A" 
d="M22.123,17.065v2.171c0,0.678,0.346,1.173,1.397,1.173s1.397-0.495,1.397-1.173v-2.171
+               c0-0.678-0.346-1.172-1.397-1.172S22.123,16.387,22.123,17.065z 
M22.694,17.127c0-0.495,0.203-0.731,0.827-0.731
+               
c0.624,0,0.827,0.236,0.827,0.731v2.048c0,0.495-0.203,0.733-0.827,0.733c-0.624,0-0.827-0.238-0.827-0.733V17.127z"/>
+        <path fill="#C9E8FB" 
d="M13.931,27.352c0.005,0.035,0.036,0.051,0.091,0.051c0.04,0,0.234-0.083,0.345-0.139l0.064-0.031v3.796
+               
c0,0.158,0.051,0.182,0.229,0.182h0.18c0.118,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.247,0-0.631,0.175-0.815,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.015,0.132,0.028,0.199
+               C13.923,27.312,13.928,27.333,13.931,27.352z"/>
+        <path fill="#0E3A5A" 
d="M13.931,27.352c0.005,0.035,0.036,0.051,0.091,0.051c0.04,0,0.234-0.083,0.345-0.139l0.064-0.031v3.796
+               
c0,0.158,0.051,0.182,0.229,0.182h0.18c0.118,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.247,0-0.631,0.175-0.815,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.015,0.132,0.028,0.199
+               C13.923,27.312,13.928,27.333,13.931,27.352z"/>
+        <path fill="#C9E8FB" 
d="M17.531,26.694c-1.051,0-1.397,0.496-1.397,1.174v2.17c0,0.679,0.346,1.173,1.397,1.173
+               
c1.051,0,1.397-0.494,1.397-1.173v-2.17C18.928,27.19,18.581,26.694,17.531,26.694z
 M18.358,29.976
+               
c0,0.496-0.203,0.732-0.827,0.732s-0.828-0.236-0.828-0.732v-2.047c0-0.496,0.204-0.733,0.828-0.733s0.827,0.237,0.827,0.733
+               V29.976z"/>
+        <path fill="#0E3A5A" 
d="M17.531,26.694c-1.051,0-1.397,0.496-1.397,1.174v2.17c0,0.679,0.346,1.173,1.397,1.173
+               
c1.051,0,1.397-0.494,1.397-1.173v-2.17C18.928,27.19,18.581,26.694,17.531,26.694z
 M18.358,29.976
+               
c0,0.496-0.203,0.732-0.827,0.732s-0.828-0.236-0.828-0.732v-2.047c0-0.496,0.204-0.733,0.828-0.733s0.827,0.237,0.827,0.733
+               V29.976z"/>
+        <path fill="#C9E8FB" 
d="M19.921,27.352c0.005,0.035,0.036,0.051,0.09,0.051c0.04,0,0.235-0.083,0.346-0.139l0.063-0.031v3.796
+               
c0,0.158,0.052,0.182,0.229,0.182h0.18c0.118,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.246,0-0.63,0.175-0.815,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.014,0.132,0.028,0.199
+               C19.913,27.312,19.917,27.333,19.921,27.352z"/>
+        <path fill="#0E3A5A" 
d="M19.921,27.352c0.005,0.035,0.036,0.051,0.09,0.051c0.04,0,0.235-0.083,0.346-0.139l0.063-0.031v3.796
+               
c0,0.158,0.052,0.182,0.229,0.182h0.18c0.118,0,0.162-0.05,0.162-0.182v-4.162c0-0.065-0.021-0.173-0.162-0.173
+               
c-0.246,0-0.63,0.175-0.815,0.258c-0.098,0.043-0.134,0.081-0.134,0.141c0,0.063,0.014,0.132,0.028,0.199
+               C19.913,27.312,19.917,27.333,19.921,27.352z"/>
+        <path fill="#C9E8FB" 
d="M23.521,26.694c-1.051,0-1.397,0.496-1.397,1.174v2.17c0,0.679,0.346,1.173,1.397,1.173
+               
s1.397-0.494,1.397-1.173v-2.17C24.918,27.19,24.572,26.694,23.521,26.694z 
M24.347,29.976c0,0.496-0.203,0.732-0.827,0.732
+               
c-0.624,0-0.827-0.236-0.827-0.732v-2.047c0-0.496,0.203-0.733,0.827-0.733c0.624,0,0.827,0.237,0.827,0.733V29.976z"/>
+        <path fill="#0E3A5A" 
d="M23.521,26.694c-1.051,0-1.397,0.496-1.397,1.174v2.17c0,0.679,0.346,1.173,1.397,1.173
+               
s1.397-0.494,1.397-1.173v-2.17C24.918,27.19,24.572,26.694,23.521,26.694z 
M24.347,29.976c0,0.496-0.203,0.732-0.827,0.732
+               
c-0.624,0-0.827-0.236-0.827-0.732v-2.047c0-0.496,0.203-0.733,0.827-0.733c0.624,0,0.827,0.237,0.827,0.733V29.976z"/>
+    </g>
+</svg>
diff --git a/plugins/transforms/surefirereport/src/main/resources/version.xml 
b/plugins/transforms/surefirereport/src/main/resources/version.xml
new file mode 100644
index 0000000000..36ab20e22e
--- /dev/null
+++ b/plugins/transforms/surefirereport/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You 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.
+  ~
+  -->
+
+<version>${project.version}</version>
diff --git 
a/plugins/transforms/surefirereport/src/test/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputMetaTest.java
 
b/plugins/transforms/surefirereport/src/test/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputMetaTest.java
new file mode 100644
index 0000000000..c5cba77612
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/test/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportOutputMetaTest.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class SurefireReportOutputMetaTest {
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
+
+  @Test
+  void testTransformMeta() throws HopException {
+    List<String> attributes =
+        Arrays.asList(
+            "filename",
+            "suiteName",
+            "createParentFolder",
+            "testNameField",
+            "durationField",
+            "durationInMilliseconds",
+            "resultField",
+            "systemOutField",
+            "systemErrField",
+            "failureMessageField",
+            "failureTypeField",
+            "failOnTestFailure");
+
+    Map<String, String> getterMap = new HashMap<>();
+    Map<String, String> setterMap = new HashMap<>();
+
+    LoadSaveTester loadSaveTester =
+        new LoadSaveTester(SurefireReportOutputMeta.class, attributes, 
getterMap, setterMap);
+    loadSaveTester.testSerialization();
+  }
+}
diff --git 
a/plugins/transforms/surefirereport/src/test/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportWriterTest.java
 
b/plugins/transforms/surefirereport/src/test/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportWriterTest.java
new file mode 100644
index 0000000000..f480a1de8b
--- /dev/null
+++ 
b/plugins/transforms/surefirereport/src/test/java/org/apache/hop/pipeline/transforms/surefirereport/SurefireReportWriterTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hop.pipeline.transforms.surefirereport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import 
org.apache.hop.pipeline.transforms.surefirereport.SurefireTestCase.Status;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class SurefireReportWriterTest {
+
+  @TempDir Path tempDir;
+
+  @Test
+  void writesEmptySuite() throws Exception {
+    Path report = tempDir.resolve("surefile_empty.xml");
+    SurefireReportWriter.write(report, "empty", List.of());
+
+    String xml = Files.readString(report, StandardCharsets.UTF_8);
+    assertTrue(xml.contains("name=\"empty\""));
+    assertTrue(xml.contains("tests=\"0\""));
+    assertTrue(xml.contains("failures=\"0\""));
+    assertTrue(xml.contains("errors=\"0\""));
+    assertTrue(xml.contains("skipped=\"0\""));
+    assertFalse(xml.contains("<testcase"));
+  }
+
+  @Test
+  void writesMixedResultsAndEscapes() throws Exception {
+    Path report = tempDir.resolve("nested/surefile_mixed.xml");
+    List<SurefireTestCase> cases = new ArrayList<>();
+    cases.add(
+        new SurefireTestCase("pass-test", 1.5, Status.PASS, "hello ]]> world", 
null, null, null));
+    cases.add(
+        new SurefireTestCase(
+            "fail\"test", 2.0, Status.FAIL, "out", "err", "boom & more", 
"AssertionError"));
+    cases.add(new SurefireTestCase("skip-test", 0.0, Status.SKIP, null, null, 
"not now", null));
+    cases.add(new SurefireTestCase("error-test", 3.0, Status.ERROR, null, 
null, "oops", "Error"));
+
+    SurefireReportWriter.write(report, "suite & name", cases);
+
+    String xml = Files.readString(report, StandardCharsets.UTF_8);
+    assertTrue(xml.contains("name=\"suite &amp; name\""));
+    assertTrue(xml.contains("tests=\"4\""));
+    assertTrue(xml.contains("failures=\"1\""));
+    assertTrue(xml.contains("errors=\"1\""));
+    assertTrue(xml.contains("skipped=\"1\""));
+    assertTrue(xml.contains("name=\"pass-test\""));
+    assertTrue(xml.contains("name=\"fail&quot;test\""));
+    assertTrue(xml.contains("<failure type=\"AssertionError\" message=\"boom 
&amp; more\""));
+    assertTrue(xml.contains("<error type=\"Error\" message=\"oops\""));
+    assertTrue(xml.contains("<skipped message=\"not now\"/>"));
+    // CDATA terminator must be split safely
+    assertTrue(xml.contains("]]]]><![CDATA[>"));
+    assertTrue(xml.contains("hello "));
+    assertTrue(xml.contains(" world"));
+  }
+
+  @Test
+  void formatTimeUsesIntegerWhenWhole() {
+    assertEquals("6", SurefireReportWriter.formatTime(6.0));
+    assertEquals("1.500", SurefireReportWriter.formatTime(1.5));
+  }
+
+  @Test
+  void escapeAttributeHandlesSpecialChars() {
+    assertEquals("&amp;&lt;&gt;&quot;&apos;", 
SurefireReportWriter.escapeAttribute("&<>\"'"));
+  }
+
+  @Test
+  void normalizeTestNameStripsMainWorkflowFilename() {
+    assertEquals(
+        "0001-add-sequence",
+        
SurefireReportOutput.normalizeTestName("/files/transforms/main-0001-add-sequence.hwf"));
+    assertEquals("simple", 
SurefireReportOutput.normalizeTestName("main_simple.hwf"));
+    assertEquals("already-clean", 
SurefireReportOutput.normalizeTestName("already-clean"));
+  }
+}
diff --git 
a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java
 
b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java
index 826e0100bf..b30485e566 100644
--- 
a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java
+++ 
b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutor.java
@@ -220,11 +220,19 @@ public class WorkflowExecutor extends 
BaseTransform<WorkflowExecutorMeta, Workfl
     Result result = data.executorWorkflow.startExecution();
 
     // First the natural output...
+    // Execution-result rows keep the first input row fields (e.g. filename) 
and append metrics.
     //
     if (meta.getExecutionResultTargetTransformMeta() != null) {
       Object[] outputRow = 
RowDataUtil.allocateRowData(data.executionResultsOutputRowMeta.size());
       int idx = 0;
 
+      // Copy fields from the first buffered input row
+      Object[] inputData = data.groupBuffer.get(0).getData();
+      int nrInputFields = data.inputRowMeta.size();
+      for (int i = 0; i < nrInputFields; i++) {
+        outputRow[idx++] = inputData[i];
+      }
+
       if (!Utils.isEmpty(meta.getExecutionTimeField())) {
         outputRow[idx++] = System.currentTimeMillis() - data.groupTimeStart;
       }
diff --git 
a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorMeta.java
 
b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorMeta.java
index c3dd047b72..f03da0b27d 100644
--- 
a/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorMeta.java
+++ 
b/plugins/transforms/workflowexecutor/src/main/java/org/apache/hop/pipeline/transforms/workflowexecutor/WorkflowExecutorMeta.java
@@ -286,9 +286,8 @@ public class WorkflowExecutorMeta
       IHopMetadataProvider metadataProvider)
       throws HopTransformException {
 
-    row.clear();
-
     if (nextTransform != null && 
nextTransform.equals(resultRowsTargetTransformMeta)) {
+      row.clear();
       for (WorkflowExecutorResultRows workflowExecutorResultRows : 
resultRowsField) {
         IValueMeta value;
         try {
@@ -303,71 +302,96 @@ public class WorkflowExecutorMeta
           value.setLength(
               workflowExecutorResultRows.getLength(), 
workflowExecutorResultRows.getPrecision());
         }
+        value.setOrigin(origin);
         row.addValueMeta(value);
       }
     } else if (nextTransform != null && 
nextTransform.equals(resultFilesTargetTransformMeta)) {
+      row.clear();
       if (!Utils.isEmpty(resultFilesFileNameField)) {
         IValueMeta value = new ValueMetaString("filename", 255, 0);
+        value.setOrigin(origin);
         row.addValueMeta(value);
       }
     } else if (nextTransform != null && 
nextTransform.equals(executionResultTargetTransformMeta)) {
-      if (!Utils.isEmpty(executionTimeField)) {
-        IValueMeta value = new ValueMetaInteger(executionTimeField, 15, 0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionResultField)) {
-        IValueMeta value = new ValueMetaBoolean(executionResultField);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionNrErrorsField)) {
-        IValueMeta value = new ValueMetaInteger(executionNrErrorsField, 9, 0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesReadField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesReadField, 9, 0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesWrittenField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesWrittenField, 9, 
0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesInputField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesInputField, 9, 
0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesOutputField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesOutputField, 9, 
0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesRejectedField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesRejectedField, 
9, 0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesUpdatedField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesUpdatedField, 9, 
0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLinesDeletedField)) {
-        IValueMeta value = new ValueMetaInteger(executionLinesDeletedField, 9, 
0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionFilesRetrievedField)) {
-        IValueMeta value = new ValueMetaInteger(executionFilesRetrievedField, 
9, 0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionExitStatusField)) {
-        IValueMeta value = new ValueMetaInteger(executionExitStatusField, 3, 
0);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLogTextField)) {
-        IValueMeta value = new ValueMetaString(executionLogTextField);
-        value.setLargeTextField(true);
-        row.addValueMeta(value);
-      }
-      if (!Utils.isEmpty(executionLogChannelIdField)) {
-        IValueMeta value = new ValueMetaString(executionLogChannelIdField, 50, 
0);
-        row.addValueMeta(value);
-      }
+      // Keep the incoming fields (e.g. filename from Get File Names) and 
append execution metrics.
+      addExecutionResultFields(row, origin);
+    } else {
+      row.clear();
+    }
+  }
+
+  /** Append execution-result value metas to the given row meta (does not 
clear existing fields). */
+  void addExecutionResultFields(IRowMeta row, String origin) {
+    if (!Utils.isEmpty(executionTimeField)) {
+      IValueMeta value = new ValueMetaInteger(executionTimeField, 15, 0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionResultField)) {
+      IValueMeta value = new ValueMetaBoolean(executionResultField);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionNrErrorsField)) {
+      IValueMeta value = new ValueMetaInteger(executionNrErrorsField, 9, 0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesReadField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesReadField, 9, 0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesWrittenField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesWrittenField, 9, 
0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesInputField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesInputField, 9, 0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesOutputField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesOutputField, 9, 0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesRejectedField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesRejectedField, 9, 
0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesUpdatedField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesUpdatedField, 9, 
0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLinesDeletedField)) {
+      IValueMeta value = new ValueMetaInteger(executionLinesDeletedField, 9, 
0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionFilesRetrievedField)) {
+      IValueMeta value = new ValueMetaInteger(executionFilesRetrievedField, 9, 
0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionExitStatusField)) {
+      IValueMeta value = new ValueMetaInteger(executionExitStatusField, 3, 0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLogTextField)) {
+      IValueMeta value = new ValueMetaString(executionLogTextField);
+      value.setLargeTextField(true);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
+    }
+    if (!Utils.isEmpty(executionLogChannelIdField)) {
+      IValueMeta value = new ValueMetaString(executionLogChannelIdField, 50, 
0);
+      value.setOrigin(origin);
+      row.addValueMeta(value);
     }
   }
 

Reply via email to