Repository: SFDO-Tooling/CumulusCI
Pull request #3655 (2023-09-20 09:18:03)

Added xpath 'find_and_replace' functionality to transforms
[W-12189179](https://gus.lightning.force.com/lightning/r/ADM_Work__c/a07EE00001GgiPYYAZ/view)
- Added `find_and_replace` via xpath along with find under transforms to find and replace items in xml files. 
- Added additional functionality to ensure that 'find' does not mess with the tags in the xml file and only replaces the content of the tags.


Patch:
diff --git a/cumulusci/core/source_transforms/transforms.py b/cumulusci/core/source_transforms/transforms.py
index 146802bb5..ab311b145 100644
--- a/cumulusci/core/source_transforms/transforms.py
+++ b/cumulusci/core/source_transforms/transforms.py
@@ -2,12 +2,14 @@ import abc
 import functools
 import io
 import os
+import re
 import shutil
 import typing as T
 import zipfile
 from pathlib import Path
 from zipfile import ZipFile
 
+from lxml import etree as ET
 from pydantic import BaseModel, root_validator
 
 from cumulusci.core.dependencies.utils import TaskContext
@@ -288,9 +290,29 @@ class BundleStaticResourcesTransform(SourceTransform):
 
 
 class FindReplaceBaseSpec(BaseModel, abc.ABC):
-    find: str
+    find: T.Optional[str]
+    xpath: T.Optional[str]
     paths: T.Optional[T.List[Path]] = None
 
+    @root_validator
+    def validate_find_xpath(cls, values):
+        findVal = values.get("find")
+        xpathVal = values.get("xpath")
+        if (findVal == "" or findVal is None) and (xpathVal is None or xpathVal == ""):
+            raise ValueError(
+                "Input is not valid. Please pass either find or xpath paramter."
+            )
+        if (
+            findVal != ""
+            and findVal is not None
+            and xpathVal != ""
+            and xpathVal is not None
+        ):
+            raise ValueError(
+                "Input is not valid. Please pass either find or xpath paramter not both."
+            )
+        return values
+
     @abc.abstractmethod
     def get_replace_string(self, context: TaskContext) -> str:
         ...
@@ -394,15 +416,71 @@ class FindReplaceTransform(SourceTransform):
         self.options = options
 
     def process(self, zf: ZipFile, context: TaskContext) -> ZipFile:
+        # To handle xpath with namespaces, without
+        def transform_xpath(expression):
+            predicate_pattern = re.compile(r"\[.*?\]")
+            parts = expression.split("/")
+            transformed_parts = []
+
+            for part in parts:
+                if part:
+                    predicates = predicate_pattern.findall(part)
+                    tag = predicate_pattern.sub("", part)
+                    transformed_part = '/*[local-name()="' + tag + '"]'
+                    for predicate in predicates:
+                        transformed_part += predicate
+                    transformed_parts.append(transformed_part)
+            transformed_expression = "".join(transformed_parts)
+
+            return transformed_expression
+
         def process_file(filename: str, content: str) -> T.Tuple[str, str]:
             path = Path(filename)
             for spec in self.options.patterns:
                 if not spec.paths or any(
                     parent in path.parents for parent in spec.paths
                 ):
-                    content = content.replace(
-                        spec.find, spec.get_replace_string(context)
-                    )
+                    try:
+                        # See if the content is an xml file
+                        content_bytes = content.encode("utf-8")
+                        root = ET.fromstring(content_bytes)
+
+                        # See if content has an xml declaration
+                        has_xml_declaration = content.strip().startswith("<?xml")
+
+                        # If find, we do not want to modify the tags in xml file, only the content
+                        if spec.find:
+                            stack = [root]
+                            while stack:
+                                element = stack.pop()
+                                if element.text and spec.find in element.text:
+                                    element.text = element.text.replace(
+                                        spec.find, spec.get_replace_string(context)
+                                    )
+                                stack.extend(element)
+                        # Modify the element given by xpath
+                        elif spec.xpath:
+                            transformed_xpath = transform_xpath(spec.xpath)
+                            elements_to_replace = root.xpath(transformed_xpath)
+                            for element in elements_to_replace:
+                                element.text = spec.get_replace_string(context)
+
+                        # Add xml declaration back to file, if it initally had xml declaration
+                        content = ET.tostring(
+                            root, encoding="utf-8", xml_declaration=has_xml_declaration
+                        ).decode("utf-8")
+
+                    except ET.XMLSyntaxError:
+                        if spec.find:
+                            content = content.replace(
+                                spec.find, spec.get_replace_string(context)
+                            )
+                        else:
+                            continue
+                    except ET.XPathError as e:
+                        raise ET.XPathError(
+                            f"An exception of type {type(e).__name__} occurred: {e} \nKindly check the xpath given"
+                        )
 
             return (filename, content)
 
diff --git a/docs/deploy.md b/docs/deploy.md
index df3b50aef..8f5ef95e4 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -91,7 +91,14 @@ Unlocked Packages do not support Feature Parameter metadata. When CumulusCI is b
 
 CumulusCI allows you to specify arbitrary injections of data against tokens you define in your metadata. This capability is often used, for example, to inject secure keys or tokens from environment variables.
 
-Configure injection with the `find_replace` transform like this:
+Configure injection with the `find_replace` transform in two ways - `find` and `xpath`.
+
+-   Using the `find` parameter, it modifies the metadata for each occurrence of its value by utilizing the supplied `replace` parameter. For XML files, this adjustment exclusively affects the values within the XML elements, leaving the tags unchanged.
+-   Using the `xpath` parameter, it exclusively modifies the content of the XML files within the specific element indicated by the provided `xpath`, replacing it with the specified `replace` parameter.
+
+**Note:** The Find-and-Replace Injection only supports one of the two parameters `find` or `xpath`.
+
+Injection with `find` parameter:
 
 ```yaml
 task: deploy
@@ -104,6 +111,21 @@ options:
                     replace: bar
 ```
 
+Injection with `xpath` parameter:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - xpath: /path/to/element
+                    replace: bar
+```
+
+The `xpath` also supports the use of predicates such as `- xpath: /path[1]/to/element[text()="some_text"]`
+
 To use an environment variable as the source of the value to replace, use the `replace_env` key. Note that it's valid to use multiple runs of `find_replace`; they will be applied in sequence.
 
 ```yaml
@@ -115,14 +137,16 @@ options:
               patterns:
                   - find: AUTH_TOKEN
                     replace_env: SECURE_ACCESS_KEY
-                  - find: foo
-                    replace: bar
+                  - xpath: /path/to/AUTH_TOKEN
+                    replace_env: SECURE_ACCESS_KEY
 ```
 
 #### Find-and-Replace Id Injection
 
 Some metadata components contain ID references. CumulusCI offers a way to insert the ID of an existing record from the target org directly into such components during a deployment. Specify a SOQL query with the `replace_record_id_query` option as follows:
 
+Injection with `find` parameter:
+
 ```yaml
 task: deploy
 options:
@@ -135,12 +159,28 @@ options:
                     api: rest
 ```
 
+Injection with `xpath` parameter:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - xpath: /path/to/element
+                    replace_record_id_query: SELECT Id from Account WHERE name='Specific Account'
+                    api: rest
+```
+
 Available values for `api` include `rest` and `tooling`.
 
 #### Find-and-Replace Current Username Injection
 
 CumulusCI can replace a given token with the username of the current running user in the target Salesforce org.
-All that is needed is to specify a value for `find` and set `inject_username: True`:
+All that is needed is to specify a value for `find` or `xpath` and set `inject_username: True`:
+
+Injection with `find` parameter:
 
 ```yaml
 task: deploy
@@ -153,10 +193,25 @@ options:
                     inject_username: True
 ```
 
+Injection with `xpath` parameter:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - xpath: /path/to/element
+                    inject_username: True
+```
+
 #### Find-and-Replace Org URL Injection
 
 CumulusCI can replace a given token with the org URL of the target Salesforce org.
-All that is needed is to specify a value for `find` and set `inject_org_url: True`:
+All that is needed is to specify a value for `find` or `xpath` and set `inject_org_url: True`:
+
+Injection with `find` parameter:
 
 ```yaml
 task: deploy
@@ -169,6 +224,19 @@ options:
                     inject_org_url: True
 ```
 
+Injection with `xpath` parameter:
+
+```yaml
+task: deploy
+options:
+    transforms:
+        - transform: find_replace
+          options:
+              patterns:
+                  - xpath: /path/to/element
+                    inject_org_url: True
+```
+
 ### Stripping Components with a `package.xml` Manifest
 
 This transformation allows you to deploy a subset of a metadata directory based on a `package.xml` manifest by removing unwanted components. It will compare components available in the source folder with a provided `package.xml` file and delete/modify component files which are not found.


Test patch:
diff --git a/cumulusci/core/source_transforms/tests/test_transforms.py b/cumulusci/core/source_transforms/tests/test_transforms.py
index 4fb9015d5..99230f2fe 100644
--- a/cumulusci/core/source_transforms/tests/test_transforms.py
+++ b/cumulusci/core/source_transforms/tests/test_transforms.py
@@ -7,6 +7,7 @@ from unittest import mock
 from zipfile import ZipFile
 
 import pytest
+from lxml import etree as ET
 from pydantic import ValidationError
 
 from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError
@@ -376,304 +377,546 @@ def test_bundle_static_resources(task_context):
         assert compare_spec == zf
 
 
-def test_find_replace_static(task_context):
+def create_builder(task_context, zip_content, patterns):
     builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec(
-            {
-                Path("Foo.cls"): "System.debug('blah');",
-            }
-        ).as_zipfile(),
+        ZipFileSpec(zip_content).as_zipfile(),
         context=task_context,
         transforms=[
             FindReplaceTransform(
-                FindReplaceTransformOptions.parse_obj(
-                    {"patterns": [{"find": "bl", "replace": "ye"}]}
-                )
+                FindReplaceTransformOptions.parse_obj({"patterns": patterns})
             )
         ],
     )
 
-    assert (
-        ZipFileSpec(
-            {
-                Path("Foo.cls"): "System.debug('yeah');",
-            }
-        )
-        == builder.zf
-    )
+    return builder
+
+
+def zip_assert(builder, modified_zip_content):
+    assert ZipFileSpec(modified_zip_content) == builder.zf
+
+
+def test_find_replace_static(task_context):
+    zip_content = {
+        Path("Foo.cls"): "System.debug('blah');",
+    }
+    patterns = [{"find": "bl", "replace": "ye"}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path("Foo.cls"): "System.debug('yeah');",
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_static(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): "<root><element1>blah</element1><element2>blah</element2></root>",
+    }
+    patterns = [{"xpath": "/root/element1", "replace": "yeah"}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): "<root><element1>yeah</element1><element2>blah</element2></root>",
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_find_replace_xmlFile(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): "<root><blement1>blah</blement1><element2><element3>blah</element3></element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    patterns = [{"find": "bl", "replace": "ye"}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): "<root><blement1>yeah</blement1><element2><element3>yeah</element3></element2></root>",
+        Path("Bar.cls"): "System.debug('yeah');",
+    }
+    zip_assert(builder, modified_zip_content)
 
 
 def test_find_replace_environ(task_context):
     with mock.patch.dict(os.environ, {"INSERT_TEXT": "ye"}):
-        builder = MetadataPackageZipBuilder.from_zipfile(
-            ZipFileSpec(
-                {
-                    Path("Foo.cls"): "System.debug('blah');",
-                }
-            ).as_zipfile(),
-            context=task_context,
-            transforms=[
-                FindReplaceTransform(
-                    FindReplaceTransformOptions.parse_obj(
-                        {"patterns": [{"find": "bl", "replace_env": "INSERT_TEXT"}]}
-                    )
-                )
-            ],
-        )
+        zip_content = {
+            Path("Foo.cls"): "System.debug('blah');",
+        }
+        patterns = [{"find": "bl", "replace_env": "INSERT_TEXT"}]
+        builder = create_builder(task_context, zip_content, patterns)
 
-        assert (
-            ZipFileSpec(
-                {
-                    Path("Foo.cls"): "System.debug('yeah');",
-                }
-            )
-            == builder.zf
-        )
+        modified_zip_content = {
+            Path("Foo.cls"): "System.debug('yeah');",
+        }
+        zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_environ(task_context):
+    with mock.patch.dict(os.environ, {"INSERT_TEXT": "yeah"}):
+        zip_content = {
+            Path(
+                "Foo.xml"
+            ): "<root><element1>blah</element1><element2>blah</element2></root>",
+        }
+        patterns = [{"xpath": "/root/element1", "replace_env": "INSERT_TEXT"}]
+        builder = create_builder(task_context, zip_content, patterns)
+
+        modified_zip_content = {
+            Path(
+                "Foo.xml"
+            ): "<root><element1>yeah</element1><element2>blah</element2></root>",
+        }
+        zip_assert(builder, modified_zip_content)
 
 
 def test_find_replace_environ__not_found(task_context):
     assert "INSERT_TEXT" not in os.environ
     with pytest.raises(TaskOptionsError):
-        MetadataPackageZipBuilder.from_zipfile(
-            ZipFileSpec(
-                {
-                    Path("Foo.cls"): "System.debug('blah');",
-                }
-            ).as_zipfile(),
-            context=task_context,
-            transforms=[
-                FindReplaceTransform(
-                    FindReplaceTransformOptions.parse_obj(
-                        {"patterns": [{"find": "bl", "replace_env": "INSERT_TEXT"}]}
-                    )
-                )
-            ],
-        )
+        zip_content = {
+            Path("Foo.cls"): "System.debug('blah');",
+        }
+        patterns = [{"find": "bl", "replace_env": "INSERT_TEXT"}]
+        create_builder(task_context, zip_content, patterns)
 
 
-def test_find_replace_filtered(task_context):
-    builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('blah');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        ).as_zipfile(),
-        context=task_context,
-        transforms=[
-            FindReplaceTransform(
-                FindReplaceTransformOptions.parse_obj(
-                    {
-                        "patterns": [
-                            {"find": "bl", "replace": "ye", "paths": ["classes"]}
-                        ]
-                    }
-                )
-            )
-        ],
-    )
+def test_xpath_replace_environ__not_found(task_context):
+    assert "INSERT_TEXT" not in os.environ
+    with pytest.raises(TaskOptionsError):
+        zip_content = {
+            Path(
+                "Foo.xml"
+            ): "<root><element1>blah</element1><element2>blah</element2></root>",
+        }
+        patterns = [{"xpath": "/root/element1", "replace_env": "INSERT_TEXT"}]
+        create_builder(task_context, zip_content, patterns)
 
-    assert (
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('yeah');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        )
-        == builder.zf
-    )
 
+def test_find_replace_filtered(task_context):
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('blah');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    patterns = [{"find": "bl", "replace": "ye", "paths": ["classes"]}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('yeah');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_filtered(task_context):
+    zip_content = {
+        Path("classes")
+        / "Foo.xml": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path(
+            "Bar.cls"
+        ): "<root><element3>blah</element3><element4>blah</element4></root>",
+    }
+    patterns = [{"xpath": "/root/element1", "replace": "yeah", "paths": ["classes"]}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path("classes")
+        / "Foo.xml": "<root><element1>yeah</element1><element2>blah</element2></root>",
+        Path(
+            "Bar.cls"
+        ): "<root><element3>blah</element3><element4>blah</element4></root>",
+    }
+    zip_assert(builder, modified_zip_content)
 
-def test_find_replace_multiple(task_context):
-    builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('blah');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        ).as_zipfile(),
-        context=task_context,
-        transforms=[
-            FindReplaceTransform(
-                FindReplaceTransformOptions.parse_obj(
-                    {
-                        "patterns": [
-                            {"find": "bl", "replace": "ye", "paths": ["classes"]},
-                            {"find": "ye", "replace": "ha"},
-                        ]
-                    }
-                )
-            )
-        ],
-    )
 
-    assert (
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('haah');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        )
-        == builder.zf
-    )
+def test_find_replace_multiple(task_context):
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('blah');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    patterns = [
+        {"find": "bl", "replace": "ye", "paths": ["classes"]},
+        {"find": "ye", "replace": "ha"},
+    ]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('haah');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_multiple(task_context):
+    zip_content = {
+        Path("classes")
+        / "Foo.xml": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path(
+            "Bar.cls"
+        ): "<root><element3>blah</element3><element4>blah</element4></root>",
+    }
+    patterns = [
+        {"xpath": "/root/element1", "replace": "yeah", "paths": ["classes"]},
+        {"xpath": "/root/element1", "replace": "haah", "paths": ["classes"]},
+        {"xpath": "/root/element3", "replace": "yeah", "paths": ["classes"]},
+    ]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path("classes")
+        / "Foo.xml": "<root><element1>haah</element1><element2>blah</element2></root>",
+        Path(
+            "Bar.cls"
+        ): "<root><element3>blah</element3><element4>blah</element4></root>",
+    }
+    zip_assert(builder, modified_zip_content)
 
 
 def test_find_replace_current_user(task_context):
-    options = FindReplaceTransformOptions.parse_obj(
-        {
-            "patterns": [
-                {"find": "%%%CURRENT_USER%%%", "inject_username": True},
-            ]
-        }
-    )
-    builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('%%%CURRENT_USER%%%');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        ).as_zipfile(),
-        context=task_context,
-        transforms=[FindReplaceTransform(options)],
-    )
-
+    patterns = [
+        {"find": "%%%CURRENT_USER%%%", "inject_username": True},
+    ]
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('%%%CURRENT_USER%%%');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    builder = create_builder(task_context, zip_content, patterns)
     expected_username = task_context.org_config.username
-    assert (
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": f"System.debug('{expected_username}');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        )
-        == builder.zf
-    )
+    modified_zip_content = {
+        Path("classes") / "Foo.cls": f"System.debug('{expected_username}');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_current_user(task_context):
+    patterns = [
+        {"xpath": "/root/element1", "inject_username": True},
+    ]
+    zip_content = {
+        Path("classes")
+        / "Foo.cls": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    builder = create_builder(task_context, zip_content, patterns)
+    expected_username = task_context.org_config.username
+    modified_zip_content = {
+        Path("classes")
+        / "Foo.cls": f"<root><element1>{expected_username}</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
 
 
 def test_find_replace_org_url(task_context):
-    options = FindReplaceTransformOptions.parse_obj(
+    patterns = [
         {
-            "patterns": [
-                {
-                    "find": "{url}",
-                    "inject_org_url": True,
-                },
-            ]
-        }
-    )
-    builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('{url}');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        ).as_zipfile(),
-        context=task_context,
-        transforms=[FindReplaceTransform(options)],
-    )
+            "find": "{url}",
+            "inject_org_url": True,
+        },
+    ]
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('{url}');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    builder = create_builder(task_context, zip_content, patterns)
+    instance_url = task_context.org_config.instance_url
+    modified_zip_content = {
+        Path("classes") / "Foo.cls": f"System.debug('{instance_url}');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
 
+
+def test_xpath_replace_org_url(task_context):
+    patterns = [
+        {
+            "xpath": "/root/element1",
+            "inject_org_url": True,
+        },
+    ]
+    zip_content = {
+        Path("classes")
+        / "Foo.cls": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    builder = create_builder(task_context, zip_content, patterns)
     instance_url = task_context.org_config.instance_url
-    assert (
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": f"System.debug('{instance_url}');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        )
-        == builder.zf
-    )
+    modified_zip_content = {
+        Path("classes")
+        / "Foo.cls": f"<root><element1>{instance_url}</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
 
 
 @pytest.mark.parametrize("api", [FindReplaceIdAPI.REST, FindReplaceIdAPI.TOOLING])
-def test_find_replace_id(api):
+def test_xpath_replace_id(api):
     context = mock.Mock()
     result = {"totalSize": 1, "records": [{"Id": "00D"}]}
     context.org_config.salesforce_client.query.return_value = result
     context.org_config.tooling.query.return_value = result
-    options = FindReplaceTransformOptions.parse_obj(
+    patterns = [
         {
-            "patterns": [
-                {
-                    "find": "00Y",
-                    "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
-                    "api": api,
-                },
-            ]
-        }
-    )
-    builder = MetadataPackageZipBuilder.from_zipfile(
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('00Y');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        ).as_zipfile(),
-        context=context,
-        transforms=[FindReplaceTransform(options)],
-    )
+            "xpath": "/root/element1",
+            "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+            "api": api,
+        },
+    ]
+    zip_content = {
+        Path("classes")
+        / "Foo.cls": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    builder = create_builder(context, zip_content, patterns)
+    modified_zip_content = {
+        Path("classes")
+        / "Foo.cls": "<root><element1>00D</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
 
-    assert (
-        ZipFileSpec(
-            {
-                Path("classes") / "Foo.cls": "System.debug('00D');",
-                Path("Bar.cls"): "System.debug('blah');",
-            }
-        )
-        == builder.zf
-    )
+
+@pytest.mark.parametrize("api", [FindReplaceIdAPI.REST, FindReplaceIdAPI.TOOLING])
+def test_find_replace_id(api):
+    context = mock.Mock()
+    result = {"totalSize": 1, "records": [{"Id": "00D"}]}
+    context.org_config.salesforce_client.query.return_value = result
+    context.org_config.tooling.query.return_value = result
+    patterns = [
+        {
+            "find": "00Y",
+            "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+            "api": api,
+        },
+    ]
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('00Y');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    builder = create_builder(context, zip_content, patterns)
+    modified_zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('00D');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    zip_assert(builder, modified_zip_content)
 
 
 def test_find_replace_id__bad_query_result():
     context = mock.Mock()
     result = {"totalSize": 0}
     context.org_config.salesforce_client.query.return_value = result
-    options = FindReplaceTransformOptions.parse_obj(
+    patterns = [
         {
-            "patterns": [
-                {
-                    "find": "00Y",
-                    "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
-                },
-            ]
-        }
-    )
+            "find": "00Y",
+            "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+        },
+    ]
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('00Y');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
     with pytest.raises(CumulusCIException):
-        MetadataPackageZipBuilder.from_zipfile(
-            ZipFileSpec(
-                {
-                    Path("classes") / "Foo.cls": "System.debug('00Y');",
-                    Path("Bar.cls"): "System.debug('blah');",
-                }
-            ).as_zipfile(),
-            context=context,
-            transforms=[FindReplaceTransform(options)],
-        )
+        create_builder(context, zip_content, patterns)
+
+
+def test_xpath_replace_id__bad_query_result():
+    context = mock.Mock()
+    result = {"totalSize": 0}
+    context.org_config.salesforce_client.query.return_value = result
+    patterns = [
+        {
+            "xpath": "/root/element1",
+            "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+        },
+    ]
+    zip_content = {
+        Path("classes")
+        / "Foo.cls": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    with pytest.raises(CumulusCIException):
+        create_builder(context, zip_content, patterns)
 
 
 def test_find_replace_id__no_id_returned():
     context = mock.Mock()
     result = {"totalSize": 1, "records": [{"name": "foo"}]}
     context.org_config.salesforce_client.query.return_value = result
-    options = FindReplaceTransformOptions.parse_obj(
+    patterns = [
         {
-            "patterns": [
-                {
-                    "find": "00Y",
-                    "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
-                },
-            ]
-        }
-    )
+            "find": "00Y",
+            "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+        },
+    ]
+    zip_content = {
+        Path("classes") / "Foo.cls": "System.debug('00Y');",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
     with pytest.raises(CumulusCIException):
-        MetadataPackageZipBuilder.from_zipfile(
-            ZipFileSpec(
-                {
-                    Path("classes") / "Foo.cls": "System.debug('00Y');",
-                    Path("Bar.cls"): "System.debug('blah');",
-                }
-            ).as_zipfile(),
-            context=context,
-            transforms=[FindReplaceTransform(options)],
-        )
+        create_builder(context, zip_content, patterns)
+
+
+def test_xpath_replace_id__no_id_returned():
+    context = mock.Mock()
+    result = {"totalSize": 1, "records": [{"name": "foo"}]}
+    context.org_config.salesforce_client.query.return_value = result
+    patterns = [
+        {
+            "xpath": "/root/element1",
+            "replace_record_id_query": "SELECT Id FROM Account WHERE name='Initech Corp.'",
+        },
+    ]
+    zip_content = {
+        Path("classes")
+        / "Foo.cls": "<root><element1>blah</element1><element2>blah</element2></root>",
+        Path("Bar.cls"): "System.debug('blah');",
+    }
+    with pytest.raises(CumulusCIException):
+        create_builder(context, zip_content, patterns)
+
+
+def test_find_xpath_both_none(task_context):
+    zip_content = {
+        Path("Foo.cls"): "System.debug('blah');",
+    }
+    patterns = [{"replace": "ye"}]
+    with pytest.raises(ValidationError) as e:
+        create_builder(task_context, zip_content, patterns)
+    assert "Input is not valid. Please pass either find or xpath paramter." in str(e)
+
+
+def test_find_xpath_both_empty(task_context):
+    zip_content = {
+        Path("Foo.cls"): "System.debug('blah');",
+    }
+    patterns = [{"find": "", "xpath": "", "replace": "ye"}]
+    with pytest.raises(ValidationError) as e:
+        create_builder(task_context, zip_content, patterns)
+    assert "Input is not valid. Please pass either find or xpath paramter." in str(e)
+
+
+def test_find_xpath_both(task_context):
+    zip_content = {
+        Path("Foo.cls"): "System.debug('blah');",
+    }
+    patterns = [{"find": "bl", "xpath": "bl", "replace": "ye"}]
+    with pytest.raises(ValidationError) as e:
+        create_builder(task_context, zip_content, patterns)
+    assert (
+        "Input is not valid. Please pass either find or xpath paramter not both."
+        in str(e)
+    )
+
+
+def test_invalid_xpath(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): "<root><element1>blah</element1><element2>blah</element2></root>",
+    }
+    patterns = [{"xpath": '/root/element1[tezxt()=="blah"]', "replace": "yeah"}]
+    with pytest.raises(ET.XPathError):
+        create_builder(task_context, zip_content, patterns)
+
+
+def test_xpath_replace_with_index(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    patterns = [{"xpath": "/bookstore/book[2]/title", "replace": "New Title"}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">New Title</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_with_text(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    patterns = [
+        {
+            "xpath": "/bookstore/book/title[text()='Learning XML']",
+            "replace": "Updated Text",
+        }
+    ]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Updated Text</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_with_exp_and_index(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    patterns = [
+        {"xpath": "/bookstore/book[price>40]/author[2]", "replace": "Rich Author"}
+    ]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Rich Author</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_with_exp_and_index2(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    patterns = [
+        {"xpath": "/bookstore/book[price<40]/author[1]", "replace": "Rich Author"}
+    ]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Rich Author</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>Rich Author</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Rich Author</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    zip_assert(builder, modified_zip_content)
+
+
+def test_xpath_replace_with_exp(task_context):
+    zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>James McGovern</author> <author>Per Bothner</author> <author>Kurt Cagle</author> <author>James Linn</author> <author>Vaidyanathan Nagarajan</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    patterns = [{"xpath": "/bookstore/book[price>40]/author", "replace": "Rich Author"}]
+    builder = create_builder(task_context, zip_content, patterns)
+
+    modified_zip_content = {
+        Path(
+            "Foo.xml"
+        ): '<bookstore> <book category="cooking"> <title lang="en">Everyday Italian</title> <author>Giada De Laurentiis</author> <year>2005</year> <price>30.00</price> </book> <book category="children"> <title lang="en">Harry Potter</title> <author>J K. Rowling</author> <year>2005</year> <price>29.99</price> </book> <book category="web"> <title lang="en">XQuery Kick Start</title> <author>Rich Author</author> <author>Rich Author</author> <author>Rich Author</author> <author>Rich Author</author> <author>Rich Author</author> <year>2003</year> <price>49.99</price> </book> <book category="web"> <title lang="en">Learning XML</title> <author>Erik T. Ray</author> <year>2003</year> <price>39.95</price> </book> </bookstore>',
+    }
+    zip_assert(builder, modified_zip_content)
 
 
 def test_source_transform_parsing():