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

hansva 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 8358e4806b Ingore missing plugins XML and keep existing state, fixes 
#7778 (#7779)
8358e4806b is described below

commit 8358e4806ba93f18672863c02c6c6d76528e9f6d
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed Aug 5 13:31:51 2026 +0200

    Ingore missing plugins XML and keep existing state, fixes #7778 (#7779)
---
 .../apache/hop/metadata/api/IMissingPlugin.java    |  48 ++++
 .../metadata/serializer/xml/XmlMetadataUtil.java   | 151 ++++++++++-
 .../java/org/apache/hop/pipeline/PipelineMeta.java |   7 +
 .../hop/pipeline/transform/TransformMeta.java      |  50 ++--
 .../hop/pipeline/transforms/missing/Missing.java   |  19 +-
 .../java/org/apache/hop/workflow/WorkflowMeta.java |   7 +
 .../org/apache/hop/workflow/action/ActionMeta.java |  49 +++-
 .../workflow/actions/missing/MissingAction.java    |  21 +-
 .../missing/MissingTransformSaveTest.java          | 290 +++++++++++++++++++++
 .../actions/missing/MissingActionSaveTest.java     | 234 +++++++++++++++++
 10 files changed, 838 insertions(+), 38 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/metadata/api/IMissingPlugin.java 
b/core/src/main/java/org/apache/hop/metadata/api/IMissingPlugin.java
new file mode 100644
index 0000000000..2273e2bb0e
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/metadata/api/IMissingPlugin.java
@@ -0,0 +1,48 @@
+/*
+ * 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.metadata.api;
+
+import java.util.List;
+
+/**
+ * Implemented by the placeholder objects which stand in for a plugin that 
isn't installed: a
+ * transform in a pipeline or an action in a workflow.
+ *
+ * <p>Since the plugin isn't available there is no class to de-serialize its 
settings into. To make
+ * sure that simply opening and saving the file doesn't destroy the 
configuration of that plugin,
+ * the placeholder keeps the XML of the original object untouched and writes 
it back out again on
+ * save.
+ */
+public interface IMissingPlugin {
+
+  /** The id of the plugin which couldn't be found. */
+  String getMissingPluginId();
+
+  /**
+   * The XML of the child elements of the object this placeholder replaces, 
verbatim, in the order
+   * in which they were read.
+   */
+  List<String> getPreservedXml();
+
+  /**
+   * Remember the XML of the object this placeholder replaces so it can be 
written back out on save.
+   *
+   * @param preservedXml the verbatim XML of the original child elements
+   */
+  void setPreservedXml(List<String> preservedXml);
+}
diff --git 
a/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
 
b/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
index a2470a7d59..001d32cbe2 100644
--- 
a/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
+++ 
b/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
@@ -35,6 +35,7 @@ import org.apache.hop.core.encryption.Encr;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopMissingPluginsException;
 import org.apache.hop.core.exception.HopXmlException;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.metadata.api.EmptyStringEncoder;
 import org.apache.hop.metadata.api.HopMetadataObject;
@@ -47,9 +48,11 @@ import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.api.IHopMetadataObjectFactory;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.api.IIntCodeConverter;
+import org.apache.hop.metadata.api.IMissingPlugin;
 import org.apache.hop.metadata.api.IStringEncoder;
 import org.apache.hop.metadata.util.ReflectionUtil;
 import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
 
 public class XmlMetadataUtil {
 
@@ -80,22 +83,129 @@ public class XmlMetadataUtil {
       HopMetadataObject metadataObject, Node node, String pluginId) {
     String factoryName = metadataObject.objectFactory().getName();
     String name = node != null ? XmlHandler.getTagValue(node, "name") : null;
+    Object placeholder = null;
     try {
       if (FQN_TRANSFORM_FACTORY.equals(factoryName)) {
         Class<?> c = 
Class.forName("org.apache.hop.pipeline.transforms.missing.Missing");
-        return c.getDeclaredConstructor(String.class, String.class)
-            .newInstance(Const.NVL(name, ""), pluginId);
-      }
-      if (FQN_ACTION_FACTORY.equals(factoryName)) {
+        placeholder =
+            c.getDeclaredConstructor(String.class, String.class)
+                .newInstance(Const.NVL(name, ""), pluginId);
+      } else if (FQN_ACTION_FACTORY.equals(factoryName)) {
         Class<?> c = 
Class.forName("org.apache.hop.workflow.actions.missing.MissingAction");
         String actionName = (name == null || name.isEmpty()) ? null : name;
-        return c.getDeclaredConstructor(String.class, String.class)
-            .newInstance(actionName, pluginId);
+        placeholder =
+            c.getDeclaredConstructor(String.class, 
String.class).newInstance(actionName, pluginId);
       }
     } catch (Exception ignored) {
       return null;
     }
-    return null;
+    if (placeholder instanceof IMissingPlugin missingPlugin) {
+      preserveMissingPluginXml(missingPlugin, node);
+    }
+    return placeholder;
+  }
+
+  /**
+   * Remember the XML of the object a missing plugin placeholder stands in 
for. Without this the
+   * settings of the missing plugin are lost the first time the file is saved.
+   *
+   * @param missingPlugin the placeholder to store the XML in
+   * @param node the XML node of the object the placeholder replaces
+   */
+  public static void preserveMissingPluginXml(IMissingPlugin missingPlugin, 
Node node) {
+    if (node == null) {
+      return;
+    }
+    List<String> preservedXml = new ArrayList<>();
+    NodeList children = node.getChildNodes();
+    for (int i = 0; i < children.getLength(); i++) {
+      Node child = children.item(i);
+      if (child.getNodeType() != Node.ELEMENT_NODE) {
+        continue;
+      }
+      try {
+        preservedXml.add(XmlHandler.formatNode(child));
+      } catch (HopXmlException e) {
+        // Preserving is best effort: warn but keep loading the file.
+        //
+        LogChannel.GENERAL.logError(
+            "Unable to preserve the XML of missing plugin "
+                + missingPlugin.getMissingPluginId()
+                + ", the settings of element "
+                + child.getNodeName()
+                + " will be lost when this file is saved",
+            e);
+      }
+    }
+    missingPlugin.setPreservedXml(preservedXml);
+  }
+
+  /**
+   * Get the preserved XML of a missing plugin placeholder, leaving out the 
elements which the
+   * object containing the placeholder has serialized itself.
+   *
+   * @param missingPlugin the placeholder holding the preserved XML
+   * @param serializedXml the XML which was serialized for the containing 
object so far
+   * @return the XML of the missing plugin, ready to be appended to the 
containing object's XML
+   * @throws HopException in case the XML serialized so far can't be parsed
+   */
+  public static String getPreservedMissingPluginXml(
+      IMissingPlugin missingPlugin, String serializedXml) throws HopException {
+    List<String> preservedXml = missingPlugin.getPreservedXml();
+    if (preservedXml == null || preservedXml.isEmpty()) {
+      return "";
+    }
+    Set<String> serializedTags = getTopLevelTags(serializedXml);
+    StringBuilder xml = new StringBuilder();
+    for (String elementXml : preservedXml) {
+      String tag = getRootTag(elementXml);
+      // Whatever the containing object serialized itself takes precedence: it 
knows about renames,
+      // moves on the canvas and so on. Everything else belonged to the 
missing plugin.
+      //
+      if (tag != null && !serializedTags.contains(tag)) {
+        xml.append(elementXml).append(Const.CR);
+      }
+    }
+    return xml.toString();
+  }
+
+  /** The names of the elements at the top level of an XML fragment. */
+  private static Set<String> getTopLevelTags(String xml) throws HopException {
+    Set<String> tags = new HashSet<>();
+    if (StringUtils.isBlank(xml)) {
+      return tags;
+    }
+    Node wrapper;
+    try {
+      wrapper = XmlHandler.wrapLoadXmlString(xml);
+    } catch (HopXmlException e) {
+      throw new HopException("Unable to parse the XML serialized so far: " + 
xml, e);
+    }
+    NodeList children = wrapper.getChildNodes();
+    for (int i = 0; i < children.getLength(); i++) {
+      Node child = children.item(i);
+      if (child.getNodeType() == Node.ELEMENT_NODE) {
+        tags.add(child.getNodeName());
+      }
+    }
+    return tags;
+  }
+
+  /** The name of the root element of the given XML, without parsing it. */
+  private static String getRootTag(String xml) {
+    int start = xml.indexOf('<');
+    if (start < 0) {
+      return null;
+    }
+    int end = start + 1;
+    while (end < xml.length() && !Character.isWhitespace(xml.charAt(end))) {
+      char c = xml.charAt(end);
+      if (c == '>' || c == '/') {
+        break;
+      }
+      end++;
+    }
+    return end > start + 1 ? xml.substring(start + 1, end) : null;
   }
 
   /**
@@ -181,6 +291,7 @@ public class XmlMetadataUtil {
     //
     List<Field> fields =
         ReflectionUtil.findAllFields(objectClass, new 
MetadataPropertyKeyFunction(), false);
+    IMissingPlugin missingPlugin = null;
     for (Field field : fields) {
       // Is this field appropriate to be considered for serialization?
       // We check it with the method below.
@@ -202,7 +313,15 @@ public class XmlMetadataUtil {
 
         // Add the field value to the XML
         //
-        serializeFieldValueToXml(parentObject, field, isBoolean, property, 
xml, tag, groupKey);
+        Object value =
+            serializeFieldValueToXml(parentObject, field, isBoolean, property, 
xml, tag, groupKey);
+
+        // A placeholder for a plugin which isn't installed carries the XML of 
the object it
+        // replaces. We write it back out below, once we know what was 
serialized here.
+        //
+        if (value instanceof IMissingPlugin plugin) {
+          missingPlugin = plugin;
+        }
       }
     }
 
@@ -227,6 +346,14 @@ public class XmlMetadataUtil {
               object, methodProperty, methodProperty.groupKey(), 
methodProperty.key()));
     }
 
+    // Write back the settings of a plugin which isn't installed. Everything 
this object serialized
+    // itself wins, the rest of the original XML is written out untouched.
+    //
+    if (missingPlugin != null) {
+      int bodyStart = wrapper == null ? 0 : 
XmlHandler.openTag(wrapper.tag()).length();
+      xml.append(getPreservedMissingPluginXml(missingPlugin, 
xml.substring(bodyStart)));
+    }
+
     if (wrapper != null) {
       xml.append(XmlHandler.closeTag(wrapper.tag()));
     }
@@ -259,7 +386,12 @@ public class XmlMetadataUtil {
     return result;
   }
 
-  private static void serializeFieldValueToXml(
+  /**
+   * Serialize the value of the given field to XML.
+   *
+   * @return the value of the field which was serialized (or null)
+   */
+  private static Object serializeFieldValueToXml(
       Object parentObject,
       Field field,
       boolean isBoolean,
@@ -285,6 +417,7 @@ public class XmlMetadataUtil {
         xml.append(serializeObjectToXml(value, property, groupKey, tag));
       }
     }
+    return value;
   }
 
   private static Class<? extends IIntCodeConverter> getIntCodeConverter(
diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
index b53a7c7db2..f9f9f10c63 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
@@ -1641,6 +1641,7 @@ public class PipelineMeta extends AbstractMeta
    * that need to be set. This is happening here.
    */
   public void lookupReferencesAfterLoading() {
+    missingPipeline = null;
     for (TransformMeta transformMeta : transforms) {
       ITransformMeta iTransform = transformMeta.getTransform();
 
@@ -1650,6 +1651,12 @@ public class PipelineMeta extends AbstractMeta
       // Also set the parent pipeline to which this transform belongs.
       // This is rarely used, for example in getTableFields.getTableFields()
       transformMeta.setParentPipelineMeta(this);
+
+      // Keep track of the transforms whose plugin isn't installed so we can 
warn about them.
+      //
+      if (iTransform instanceof Missing missing) {
+        addMissingPipeline(missing);
+      }
     }
     syncTransformErrorHandlingWithHops();
   }
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java 
b/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
index 4ba0e2f6ae..735bc4d3ec 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
@@ -44,6 +44,8 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.IHasName;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IMissingPlugin;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.stream.IStream;
 import org.apache.hop.pipeline.transforms.missing.Missing;
@@ -186,34 +188,43 @@ public class TransformMeta
 
   public String getXml() throws HopException {
 
-    StringBuilder xml = new StringBuilder(200);
+    StringBuilder body = new StringBuilder(200);
 
-    xml.append("  ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
-    xml.append("    ").append(XmlHandler.addTagValue("name", getName()));
-    xml.append("    ").append(XmlHandler.addTagValue("type", 
getTransformPluginId()));
-    xml.append("    ").append(XmlHandler.addTagValue("description", 
description));
-    xml.append("    ").append(XmlHandler.addTagValue("distribute", 
distributes));
-    xml.append("    ")
+    body.append("    ").append(XmlHandler.addTagValue("name", getName()));
+    body.append("    ").append(XmlHandler.addTagValue("type", 
getTransformPluginId()));
+    body.append("    ").append(XmlHandler.addTagValue("description", 
description));
+    body.append("    ").append(XmlHandler.addTagValue("distribute", 
distributes));
+    body.append("    ")
         .append(
             XmlHandler.addTagValue(
                 "custom_distribution", rowDistribution == null ? null : 
rowDistribution.getCode()));
-    xml.append("    ").append(XmlHandler.addTagValue("copies", copiesString));
+    body.append("    ").append(XmlHandler.addTagValue("copies", copiesString));
 
-    xml.append(transformPartitioningMeta.getXml());
+    body.append(transformPartitioningMeta.getXml());
     if (targetTransformPartitioningMeta != null) {
-      xml.append(XmlHandler.openTag(CONST_TARGET_TRANSFORM_PARTITIONING))
+      body.append(XmlHandler.openTag(CONST_TARGET_TRANSFORM_PARTITIONING))
           .append(targetTransformPartitioningMeta.getXml())
           .append(XmlHandler.closeTag(CONST_TARGET_TRANSFORM_PARTITIONING));
     }
 
-    xml.append(transform.getXml());
+    body.append(transform.getXml());
+
+    body.append(AttributesUtil.getAttributesXml(attributesMap));
 
-    xml.append(AttributesUtil.getAttributesXml(attributesMap));
+    body.append("    ").append(XmlHandler.openTag("GUI")).append(Const.CR);
+    body.append("      ").append(XmlHandler.addTagValue("xloc", location.x));
+    body.append("      ").append(XmlHandler.addTagValue("yloc", location.y));
+    body.append("    ").append(XmlHandler.closeTag("GUI")).append(Const.CR);
 
-    xml.append("    ").append(XmlHandler.openTag("GUI")).append(Const.CR);
-    xml.append("      ").append(XmlHandler.addTagValue("xloc", location.x));
-    xml.append("      ").append(XmlHandler.addTagValue("yloc", location.y));
-    xml.append("    ").append(XmlHandler.closeTag("GUI")).append(Const.CR);
+    // The settings of a transform whose plugin isn't installed are written 
back out untouched.
+    //
+    if (transform instanceof IMissingPlugin missingPlugin) {
+      body.append(XmlMetadataUtil.getPreservedMissingPluginXml(missingPlugin, 
body.toString()));
+    }
+
+    StringBuilder xml = new StringBuilder(body.length() + 100);
+    xml.append("  ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
+    xml.append(body);
     xml.append("    
").append(XmlHandler.closeTag(XML_TAG)).append(Const.CR).append(Const.CR);
 
     return xml.toString();
@@ -239,7 +250,12 @@ public class TransformMeta
           registry.findPluginWithId(TransformPluginType.class, 
transformPluginId, true);
 
       if (transformPlugin == null) {
-        setTransform(new Missing(name, transformPluginId));
+        // The plugin isn't installed: keep the XML of the transform so that 
saving the pipeline
+        // doesn't throw away its configuration.
+        //
+        Missing missing = new Missing(name, transformPluginId);
+        XmlMetadataUtil.preserveMissingPluginXml(missing, transformNode);
+        setTransform(missing);
       } else {
         setTransform((ITransformMeta) registry.loadClass(transformPlugin));
       }
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java 
b/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
index 3f50fae925..aa219f0719 100644
--- 
a/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
+++ 
b/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
@@ -17,18 +17,35 @@
 
 package org.apache.hop.pipeline.transforms.missing;
 
+import java.util.ArrayList;
+import java.util.List;
 import lombok.Getter;
 import lombok.Setter;
+import org.apache.hop.metadata.api.IMissingPlugin;
 import org.apache.hop.pipeline.transform.BaseTransformMeta;
 
+/**
+ * Stands in for a transform whose plugin isn't installed. It keeps the XML of 
the original
+ * transform untouched so that opening and saving the pipeline doesn't destroy 
the configuration of
+ * that transform.
+ */
 @Getter
 @Setter
-public class Missing extends BaseTransformMeta<MissingTransform, MissingData> {
+public class Missing extends BaseTransformMeta<MissingTransform, MissingData>
+    implements IMissingPlugin {
   private String transformName;
   private String missingPluginId;
+  private List<String> preservedXml = new ArrayList<>();
 
   public Missing(String transformName, String missingPluginId) {
     this.transformName = transformName;
     this.missingPluginId = missingPluginId;
   }
+
+  @Override
+  public Object clone() {
+    Missing clone = (Missing) super.clone();
+    clone.preservedXml = new ArrayList<>(preservedXml);
+    return clone;
+  }
 }
diff --git a/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java 
b/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
index 05f2fc36a8..e21f72ac4b 100644
--- a/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
+++ b/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
@@ -453,6 +453,7 @@ public class WorkflowMeta extends AbstractMeta
    * that need to be set. This is happening here.
    */
   public void lookupReferencesAfterLoading() {
+    missingActions = null;
     for (ActionMeta actionMeta : workflowActions) {
       IAction action = actionMeta.getAction();
 
@@ -460,6 +461,12 @@ public class WorkflowMeta extends AbstractMeta
       // This is rarely used, for example in getTableFields.getTableFields()
       //
       action.setParentWorkflowMeta(this);
+
+      // Keep track of the actions whose plugin isn't installed so we can warn 
about them.
+      //
+      if (action instanceof MissingAction missingAction) {
+        addMissingAction(missingAction);
+      }
     }
   }
 
diff --git 
a/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java 
b/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
index 7cf847aea3..f3ad7d8553 100644
--- a/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
+++ b/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
@@ -24,6 +24,8 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.IAttributes;
 import org.apache.hop.core.attributes.AttributesUtil;
 import org.apache.hop.core.changed.IChanged;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.exception.HopXmlException;
 import org.apache.hop.core.gui.IGuiPosition;
 import org.apache.hop.core.gui.Point;
@@ -35,6 +37,8 @@ import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.IHasName;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IMissingPlugin;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
 import org.apache.hop.pipeline.transform.copy.CopyContext;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.copy.DefaultActionCopyFactory;
@@ -89,19 +93,34 @@ public class ActionMeta
   }
 
   public String getXml() {
-    StringBuilder xml = new StringBuilder(100);
+    StringBuilder body = new StringBuilder(100);
 
-    xml.append("    ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
     action.setParentWorkflowMeta(
         parentWorkflowMeta); // Attempt to set the WorkflowMeta for entries 
that need it
-    xml.append(action.getXml());
-
-    xml.append(CONST_SPACE).append(XmlHandler.addTagValue("parallel", 
launchingInParallel));
-    xml.append(CONST_SPACE).append(XmlHandler.addTagValue("xloc", location.x));
-    xml.append(CONST_SPACE).append(XmlHandler.addTagValue("yloc", location.y));
-
-    xml.append(AttributesUtil.getAttributesXml(attributesMap, 
XML_ATTRIBUTE_WORKFLOW_ACTION_COPY));
+    body.append(action.getXml());
+
+    body.append(CONST_SPACE).append(XmlHandler.addTagValue("parallel", 
launchingInParallel));
+    body.append(CONST_SPACE).append(XmlHandler.addTagValue("xloc", 
location.x));
+    body.append(CONST_SPACE).append(XmlHandler.addTagValue("yloc", 
location.y));
+
+    body.append(AttributesUtil.getAttributesXml(attributesMap, 
XML_ATTRIBUTE_WORKFLOW_ACTION_COPY));
+
+    // The settings of an action whose plugin isn't installed are written back 
out untouched.
+    //
+    if (action instanceof IMissingPlugin missingPlugin) {
+      try {
+        
body.append(XmlMetadataUtil.getPreservedMissingPluginXml(missingPlugin, 
body.toString()));
+      } catch (HopException e) {
+        throw new HopRuntimeException(
+            "Unable to serialize the settings of missing plugin "
+                + missingPlugin.getMissingPluginId(),
+            e);
+      }
+    }
 
+    StringBuilder xml = new StringBuilder(body.length() + 100);
+    xml.append("    ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
+    xml.append(body);
     xml.append("    ").append(XmlHandler.closeTag(XML_TAG)).append(Const.CR);
     return xml.toString();
   }
@@ -113,8 +132,18 @@ public class ActionMeta
       PluginRegistry registry = PluginRegistry.getInstance();
       IPlugin actionPlugin = registry.findPluginWithId(ActionPluginType.class, 
pluginId, true);
       if (actionPlugin == null) {
+        // The plugin isn't installed: keep the XML of the action so that 
saving the workflow
+        // doesn't throw away its configuration.
+        //
         String name = XmlHandler.getTagValue(actionNode, "name");
-        setAction(new MissingAction(name, pluginId));
+        MissingAction missingAction = new MissingAction(name, pluginId);
+        XmlMetadataUtil.preserveMissingPluginXml(missingAction, actionNode);
+        // Keep pointing at the plugin which is missing so the action can be 
restored once it is
+        // installed.
+        //
+        missingAction.setPluginId(pluginId);
+        missingAction.setDescription(XmlHandler.getTagValue(actionNode, 
"description"));
+        setAction(missingAction);
       } else {
         setAction(registry.loadClass(actionPlugin, IAction.class));
       }
diff --git 
a/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
 
b/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
index a39534216e..356f9b5ff0 100644
--- 
a/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
+++ 
b/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
@@ -17,23 +17,35 @@
 
 package org.apache.hop.workflow.actions.missing;
 
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.core.Result;
 import org.apache.hop.core.exception.HopWorkflowException;
 import org.apache.hop.core.exception.HopXmlException;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IMissingPlugin;
 import org.apache.hop.workflow.action.ActionBase;
 import org.apache.hop.workflow.action.IAction;
 import org.w3c.dom.Node;
 
-public class MissingAction extends ActionBase implements IAction {
+/**
+ * Stands in for an action whose plugin isn't installed. It keeps the XML of 
the original action
+ * untouched so that opening and saving the workflow doesn't destroy the 
configuration of that
+ * action.
+ */
+public class MissingAction extends ActionBase implements IAction, 
IMissingPlugin {
 
   /** Action unique identifier" */
   public static final String ID = "MISSING";
 
   private final String missingPluginId;
 
+  @Getter @Setter private List<String> preservedXml = new ArrayList<>();
+
   public MissingAction() {
     this(null, null);
   }
@@ -44,6 +56,13 @@ public class MissingAction extends ActionBase implements 
IAction {
     this.missingPluginId = missingPluginId;
   }
 
+  @Override
+  public Object clone() {
+    MissingAction clone = (MissingAction) super.clone();
+    clone.setPreservedXml(new ArrayList<>(preservedXml));
+    return clone;
+  }
+
   @Override
   public Result execute(Result previousResult, int nr) throws 
HopWorkflowException {
     previousResult.setResult(false);
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/transforms/missing/MissingTransformSaveTest.java
 
b/engine/src/test/java/org/apache/hop/pipeline/transforms/missing/MissingTransformSaveTest.java
new file mode 100644
index 0000000000..a656208a0c
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/pipeline/transforms/missing/MissingTransformSaveTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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.missing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.transforms.FakeMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * A pipeline referencing a transform plugin which isn't installed must 
survive a load/save round
+ * trip: the {@link Missing} placeholder keeps the XML of the original 
transform untouched and
+ * writes it back out again, so that saving the file doesn't destroy the 
configuration of that
+ * transform for anyone who does have the plugin installed.
+ */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class MissingTransformSaveTest {
+
+  private static final String MISSING_PLUGIN_ID = 
"ThisTransformPluginIsNotInstalled";
+  private static final String TRANSFORM_NAME = "Transform with a missing 
plugin";
+
+  private static final String PIPELINE_XML =
+      """
+      <pipeline>
+        <info>
+          <name>missing-plugin-pipeline</name>
+        </info>
+        <transform>
+          <name>Transform with a missing plugin</name>
+          <type>ThisTransformPluginIsNotInstalled</type>
+          <distribute>Y</distribute>
+          <copies>1</copies>
+          <connection>my-precious-connection</connection>
+          <schema>my-precious-schema</schema>
+          <table>my-precious-table</table>
+          <commit_size>5000</commit_size>
+          <fields>
+            <field>
+              <column_name>id</column_name>
+              <stream_name>id</stream_name>
+            </field>
+            <field>
+              <column_name>name</column_name>
+              <stream_name>name</stream_name>
+            </field>
+          </fields>
+          <attributes/>
+          <GUI>
+            <xloc>240</xloc>
+            <yloc>128</yloc>
+          </GUI>
+        </transform>
+      </pipeline>
+      """;
+
+  private PipelineMeta loadPipeline(String xml) throws Exception {
+    IVariables variables = new Variables();
+    IHopMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    return new PipelineMeta(
+        new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
+        metadataProvider,
+        variables);
+  }
+
+  /** A pipeline referencing an unknown transform plugin still loads, using a 
placeholder. */
+  @Test
+  void unknownTransformPluginLoadsAsMissingPlaceholder() throws Exception {
+    PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+    assertEquals(1, pipelineMeta.nrTransforms());
+    TransformMeta transformMeta = pipelineMeta.getTransform(0);
+
+    assertEquals(TRANSFORM_NAME, transformMeta.getName());
+    assertTrue(transformMeta.isMissing());
+    Missing missing = assertInstanceOf(Missing.class, 
transformMeta.getTransform());
+
+    assertEquals(MISSING_PLUGIN_ID, missing.getMissingPluginId());
+    assertEquals(MISSING_PLUGIN_ID, transformMeta.getTransformPluginId());
+  }
+
+  /** The XML of the transform we can't de-serialize is kept, element by 
element. */
+  @Test
+  void theOriginalXmlOfTheTransformIsPreserved() throws Exception {
+    PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+    Missing missing = (Missing) pipelineMeta.getTransform(0).getTransform();
+
+    assertEquals(
+        List.of(
+            "<name>Transform with a missing plugin</name>",
+            "<type>ThisTransformPluginIsNotInstalled</type>",
+            "<distribute>Y</distribute>",
+            "<copies>1</copies>",
+            "<connection>my-precious-connection</connection>",
+            "<schema>my-precious-schema</schema>",
+            "<table>my-precious-table</table>",
+            "<commit_size>5000</commit_size>",
+            "<fields>\n"
+                + "      <field>\n"
+                + "        <column_name>id</column_name>\n"
+                + "        <stream_name>id</stream_name>\n"
+                + "      </field>\n"
+                + "      <field>\n"
+                + "        <column_name>name</column_name>\n"
+                + "        <stream_name>name</stream_name>\n"
+                + "      </field>\n"
+                + "    </fields>",
+            "<attributes/>",
+            "<GUI>\n" + "      <xloc>240</xloc>\n" + "      
<yloc>128</yloc>\n" + "    </GUI>"),
+        missing.getPreservedXml());
+  }
+
+  /** Saving the pipeline writes the settings of the missing plugin back out. 
*/
+  @Test
+  void savingAPipelineWithAMissingTransformPluginKeepsItsConfiguration() 
throws Exception {
+    PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+    String savedXml = pipelineMeta.getXml(new Variables());
+
+    assertTrue(savedXml.contains("<name>" + TRANSFORM_NAME + "</name>"));
+    assertTrue(savedXml.contains("<type>" + MISSING_PLUGIN_ID + "</type>"));
+    assertTrue(savedXml.contains("<xloc>240</xloc>"));
+
+    // Everything the plugin itself owned is still there.
+    
assertTrue(savedXml.contains("<connection>my-precious-connection</connection>"));
+    assertTrue(savedXml.contains("<schema>my-precious-schema</schema>"));
+    assertTrue(savedXml.contains("<table>my-precious-table</table>"));
+    assertTrue(savedXml.contains("<commit_size>5000</commit_size>"));
+    assertTrue(savedXml.contains("<column_name>id</column_name>"));
+    assertTrue(savedXml.contains("<stream_name>name</stream_name>"));
+  }
+
+  /**
+   * The elements which the pipeline serializes itself are not duplicated: 
what {@link
+   * TransformMeta} writes wins, since that is where renames and moves on the 
canvas end up.
+   */
+  @Test
+  void theTransformOwnedElementsAreNotDuplicated() throws Exception {
+    PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+    pipelineMeta.getTransform(0).setName("Renamed while the plugin was 
missing");
+    pipelineMeta.getTransform(0).setLocation(500, 600);
+
+    String savedXml = transformElement(pipelineMeta.getXml(new Variables()));
+
+    assertEquals(1, countOccurrences(savedXml, "<name>"));
+    assertEquals(1, countOccurrences(savedXml, "<type>"));
+    assertEquals(1, countOccurrences(savedXml, "<GUI>"));
+    assertEquals(1, countOccurrences(savedXml, "<distribute>"));
+
+    assertTrue(savedXml.contains("<name>Renamed while the plugin was 
missing</name>"));
+    assertTrue(savedXml.contains("<xloc>500</xloc>"));
+    assertTrue(savedXml.contains("<yloc>600</yloc>"));
+    // ...and the settings of the missing plugin came along.
+    assertTrue(savedXml.contains("<table>my-precious-table</table>"));
+  }
+
+  /** Repeated load/save round trips are stable: nothing is lost and nothing 
is added. */
+  @Test
+  void theRoundTripIsStable() throws Exception {
+    String savedOnce = loadPipeline(PIPELINE_XML).getXml(new Variables());
+    String savedTwice = loadPipeline(savedOnce).getXml(new Variables());
+    String savedThrice = loadPipeline(savedTwice).getXml(new Variables());
+
+    assertEquals(savedOnce, savedTwice);
+    assertEquals(savedTwice, savedThrice);
+    assertTrue(savedThrice.contains("<table>my-precious-table</table>"));
+  }
+
+  /** Copy/paste of a transform with a missing plugin keeps its settings as 
well. */
+  @Test
+  void theLegacyTransformXmlRoundTripKeepsTheConfiguration() throws Exception {
+    TransformMeta transformMeta = loadPipeline(PIPELINE_XML).getTransform(0);
+
+    String copied = transformMeta.getXml();
+    assertTrue(copied.contains("<table>my-precious-table</table>"));
+    assertEquals(1, countOccurrences(copied, "<name>"));
+
+    TransformMeta pasted = TransformMeta.fromXml(copied);
+    assertTrue(pasted.isMissing());
+    assertEquals(MISSING_PLUGIN_ID, pasted.getTransformPluginId());
+    assertTrue(pasted.getXml().contains("<table>my-precious-table</table>"));
+  }
+
+  /**
+   * The point of it all: a pipeline saved while the plugin was missing still 
works once the plugin
+   * is installed again.
+   */
+  @Test
+  void theConfigurationIsRestoredOnceThePluginIsInstalled() throws Exception {
+    String xml =
+        """
+        <pipeline>
+          <info>
+            <name>missing-plugin-pipeline</name>
+          </info>
+          <transform>
+            <name>A fake transform</name>
+            <type>fake</type>
+            <distribute>Y</distribute>
+            <copies>1</copies>
+            <fake>this setting must survive</fake>
+            <attributes/>
+            <GUI>
+              <xloc>240</xloc>
+              <yloc>128</yloc>
+            </GUI>
+          </transform>
+        </pipeline>
+        """;
+
+    // The "fake" plugin isn't registered yet, so the pipeline loads with a 
placeholder...
+    //
+    PipelineMeta withoutPlugin = loadPipeline(xml);
+    assertTrue(withoutPlugin.getTransform(0).isMissing());
+
+    // ...and it gets saved in that state.
+    //
+    String savedXml = withoutPlugin.getXml(new Variables());
+
+    // Now install the plugin and open the saved file again.
+    //
+    PluginRegistry.getInstance()
+        .registerPluginClass(FakeMeta.class.getName(), 
TransformPluginType.class, Transform.class);
+
+    PipelineMeta withPlugin = loadPipeline(savedXml);
+    TransformMeta transformMeta = withPlugin.getTransform(0);
+
+    assertFalse(transformMeta.isMissing());
+    FakeMeta fakeMeta = assertInstanceOf(FakeMeta.class, 
transformMeta.getTransform());
+    assertEquals("this setting must survive", fakeMeta.getFake());
+  }
+
+  /** The pipeline reports the missing plugins so the user can be warned about 
them. */
+  @Test
+  void theMissingPluginIsReported() throws Exception {
+    PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+    assertTrue(pipelineMeta.hasMissingPlugins());
+    assertEquals(1, pipelineMeta.getMissingPipeline().size());
+    assertEquals(MISSING_PLUGIN_ID, 
pipelineMeta.getMissingPipeline().get(0).getMissingPluginId());
+  }
+
+  /** The {@code <transform>} element of a serialized pipeline, without the 
surrounding pipeline. */
+  private static String transformElement(String pipelineXml) {
+    return pipelineXml.substring(
+        pipelineXml.indexOf("<transform>"), 
pipelineXml.indexOf("</transform>"));
+  }
+
+  private static int countOccurrences(String xml, String tag) {
+    int count = 0;
+    int index = xml.indexOf(tag);
+    while (index >= 0) {
+      count++;
+      index = xml.indexOf(tag, index + tag.length());
+    }
+    return count;
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/workflow/actions/missing/MissingActionSaveTest.java
 
b/engine/src/test/java/org/apache/hop/workflow/actions/missing/MissingActionSaveTest.java
new file mode 100644
index 0000000000..a8f36e09d5
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/workflow/actions/missing/MissingActionSaveTest.java
@@ -0,0 +1,234 @@
+/*
+ * 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.workflow.actions.missing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * A workflow referencing an action plugin which isn't installed must survive 
a load/save round
+ * trip: the {@link MissingAction} placeholder keeps the XML of the original 
action untouched and
+ * writes it back out again, so that saving the file doesn't destroy the 
configuration of that
+ * action for anyone who does have the plugin installed.
+ *
+ * <p>This is the workflow counterpart of {@code MissingTransformSaveTest}.
+ */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class MissingActionSaveTest {
+
+  private static final String MISSING_PLUGIN_ID = 
"ThisActionPluginIsNotInstalled";
+  private static final String ACTION_NAME = "Action with a missing plugin";
+
+  private static final String WORKFLOW_XML =
+      """
+      <workflow>
+        <name>missing-plugin-workflow</name>
+        <actions>
+          <action>
+            <name>Action with a missing plugin</name>
+            <type>ThisActionPluginIsNotInstalled</type>
+            <description>Send the nightly report</description>
+            <server>my-precious-server</server>
+            <port>2525</port>
+            <destination>[email protected]</destination>
+            <subject>Nightly report</subject>
+            <attachments>
+              <attachment>report.pdf</attachment>
+            </attachments>
+            <parallel>N</parallel>
+            <xloc>240</xloc>
+            <yloc>128</yloc>
+          </action>
+        </actions>
+      </workflow>
+      """;
+
+  private WorkflowMeta loadWorkflow(String xml) throws Exception {
+    IVariables variables = new Variables();
+    IHopMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    return new WorkflowMeta(
+        new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
+        metadataProvider,
+        variables);
+  }
+
+  /** A workflow referencing an unknown action plugin still loads, using a 
placeholder. */
+  @Test
+  void unknownActionPluginLoadsAsMissingPlaceholder() throws Exception {
+    WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+    assertEquals(1, workflowMeta.nrActions());
+    ActionMeta actionMeta = workflowMeta.getAction(0);
+
+    assertEquals(ACTION_NAME, actionMeta.getName());
+    assertTrue(actionMeta.isMissing());
+    MissingAction missing = assertInstanceOf(MissingAction.class, 
actionMeta.getAction());
+
+    // The placeholder keeps pointing at the plugin which is missing, so the 
action can be restored
+    // once that plugin is installed.
+    assertEquals(MISSING_PLUGIN_ID, missing.getMissingPluginId());
+    assertEquals(MISSING_PLUGIN_ID, missing.getPluginId());
+  }
+
+  /** The XML of the action we can't de-serialize is kept, element by element. 
*/
+  @Test
+  void theOriginalXmlOfTheActionIsPreserved() throws Exception {
+    WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+    MissingAction missing = (MissingAction) 
workflowMeta.getAction(0).getAction();
+
+    assertEquals(
+        List.of(
+            "<name>Action with a missing plugin</name>",
+            "<type>ThisActionPluginIsNotInstalled</type>",
+            "<description>Send the nightly report</description>",
+            "<server>my-precious-server</server>",
+            "<port>2525</port>",
+            "<destination>[email protected]</destination>",
+            "<subject>Nightly report</subject>",
+            "<attachments>\n"
+                + "        <attachment>report.pdf</attachment>\n"
+                + "      </attachments>",
+            "<parallel>N</parallel>",
+            "<xloc>240</xloc>",
+            "<yloc>128</yloc>"),
+        missing.getPreservedXml());
+  }
+
+  /** Saving the workflow writes the settings of the missing plugin back out. 
*/
+  @Test
+  void savingAWorkflowWithAMissingActionPluginKeepsItsConfiguration() throws 
Exception {
+    WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+    String savedXml = workflowMeta.getXml(new Variables());
+
+    assertTrue(savedXml.contains("<name>" + ACTION_NAME + "</name>"));
+    assertTrue(savedXml.contains("<type>" + MISSING_PLUGIN_ID + "</type>"));
+    assertTrue(savedXml.contains("<description>Send the nightly 
report</description>"));
+    assertTrue(savedXml.contains("<xloc>240</xloc>"));
+
+    // Everything the plugin itself owned is still there.
+    assertTrue(savedXml.contains("<server>my-precious-server</server>"));
+    assertTrue(savedXml.contains("<port>2525</port>"));
+    
assertTrue(savedXml.contains("<destination>[email protected]</destination>"));
+    assertTrue(savedXml.contains("<subject>Nightly report</subject>"));
+    assertTrue(savedXml.contains("<attachment>report.pdf</attachment>"));
+  }
+
+  /**
+   * The elements which the workflow serializes itself are not duplicated: 
what {@link ActionMeta}
+   * writes wins, since that is where renames and moves on the canvas end up.
+   */
+  @Test
+  void theActionOwnedElementsAreNotDuplicated() throws Exception {
+    WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+    workflowMeta.getAction(0).setName("Renamed while the plugin was missing");
+    workflowMeta.getAction(0).setLocation(500, 600);
+
+    String savedXml = actionElement(workflowMeta.getXml(new Variables()));
+
+    assertEquals(1, countOccurrences(savedXml, "<name>"));
+    assertEquals(1, countOccurrences(savedXml, "<type>"));
+    assertEquals(1, countOccurrences(savedXml, "<xloc>"));
+    assertEquals(1, countOccurrences(savedXml, "<parallel>"));
+
+    assertTrue(savedXml.contains("<name>Renamed while the plugin was 
missing</name>"));
+    assertTrue(savedXml.contains("<xloc>500</xloc>"));
+    assertTrue(savedXml.contains("<yloc>600</yloc>"));
+    // ...and the settings of the missing plugin came along.
+    assertTrue(savedXml.contains("<server>my-precious-server</server>"));
+  }
+
+  /** Repeated load/save round trips are stable: nothing is lost and nothing 
is added. */
+  @Test
+  void theRoundTripIsStable() throws Exception {
+    String savedOnce = loadWorkflow(WORKFLOW_XML).getXml(new Variables());
+    String savedTwice = loadWorkflow(savedOnce).getXml(new Variables());
+    String savedThrice = loadWorkflow(savedTwice).getXml(new Variables());
+
+    assertEquals(stripDates(savedOnce), stripDates(savedTwice));
+    assertEquals(stripDates(savedTwice), stripDates(savedThrice));
+    assertTrue(savedThrice.contains("<server>my-precious-server</server>"));
+  }
+
+  /** Copy/paste of an action with a missing plugin keeps its settings as 
well. */
+  @Test
+  void theLegacyActionXmlRoundTripKeepsTheConfiguration() throws Exception {
+    ActionMeta actionMeta = loadWorkflow(WORKFLOW_XML).getAction(0);
+
+    String copied = actionMeta.getXml();
+    assertTrue(copied.contains("<server>my-precious-server</server>"));
+    assertEquals(1, countOccurrences(copied, "<name>"));
+    assertEquals(1, countOccurrences(copied, "<type>"));
+
+    ActionMeta pasted =
+        new ActionMeta(
+            org.apache.hop.core.xml.XmlHandler.getSubNode(
+                org.apache.hop.core.xml.XmlHandler.loadXmlString(copied), 
ActionMeta.XML_TAG),
+            new MemoryMetadataProvider(),
+            new Variables());
+
+    assertTrue(pasted.isMissing());
+    MissingAction missing = (MissingAction) pasted.getAction();
+    assertEquals(MISSING_PLUGIN_ID, missing.getMissingPluginId());
+    assertEquals(MISSING_PLUGIN_ID, missing.getPluginId());
+    
assertTrue(pasted.getXml().contains("<server>my-precious-server</server>"));
+  }
+
+  /** The workflow reports the missing plugins so the user can be warned about 
them. */
+  @Test
+  void theMissingPluginIsReported() throws Exception {
+    WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+    assertTrue(workflowMeta.hasMissingPlugins());
+  }
+
+  /** The {@code <action>} element of a serialized workflow, without the 
surrounding workflow. */
+  private static String actionElement(String workflowXml) {
+    return workflowXml.substring(workflowXml.indexOf("<action>"), 
workflowXml.indexOf("</action>"));
+  }
+
+  /** The created/modified dates change on every save, they are not what these 
tests are about. */
+  private static String stripDates(String xml) {
+    return 
xml.replaceAll("<(created|modified)_date>[^<]*</(created|modified)_date>", "");
+  }
+
+  private static int countOccurrences(String xml, String tag) {
+    int count = 0;
+    int index = xml.indexOf(tag);
+    while (index >= 0) {
+      count++;
+      index = xml.indexOf(tag, index + tag.length());
+    }
+    return count;
+  }
+}

Reply via email to