diff --git a/api-openbis-python3-pybis/src/python/tests/systemtest/__init__.py b/api-openbis-python3-pybis/src/python/tests/systemtest/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6b5d1ce929b60094a402f1ff6e10dc47f67f1e8d
--- /dev/null
+++ b/api-openbis-python3-pybis/src/python/tests/systemtest/__init__.py
@@ -0,0 +1,14 @@
+#   Copyright ETH 2007 - 2023 Zürich, Scientific IT Services
+# 
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+# 
+#        http://www.apache.org/licenses/LICENSE-2.0
+#   
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
diff --git a/api-openbis-python3-pybis/src/python/tests/systemtest/artifactrepository.py b/api-openbis-python3-pybis/src/python/tests/systemtest/artifactrepository.py
new file mode 100644
index 0000000000000000000000000000000000000000..a9cf5ab03040c52ec1994541ab9c8227528e21d6
--- /dev/null
+++ b/api-openbis-python3-pybis/src/python/tests/systemtest/artifactrepository.py
@@ -0,0 +1,143 @@
+#   Copyright ETH 2013 - 2023 Zürich, Scientific IT Services
+# 
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+# 
+#        http://www.apache.org/licenses/LICENSE-2.0
+#   
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+
+#import os
+import os.path
+import re
+import xml.dom.minidom
+
+from urllib.request import urlopen
+from util import printAndFlush
+# import os
+import os.path
+import re
+import xml.dom.minidom
+from urllib.request import urlopen
+
+from util import printAndFlush
+
+
+class ArtifactRepository():
+    """
+    Abstract artifact repository which keeps artifacts in a local repository folder.
+    The main method is getPathToArtifact() which returns the path to the requested artifact in the repository.
+    Concrete subclasses have to implement downloadArtifact().
+    """
+    def __init__(self, localRepositoryFolder):
+        """
+        Creates a new instance for the specified folder. The folder will be created if it does not exist. 
+        """
+        self.localRepositoryFolder = localRepositoryFolder
+        if not os.path.exists(localRepositoryFolder):
+            os.makedirs(localRepositoryFolder)
+        printAndFlush("Artifact repository: %s" % localRepositoryFolder)
+            
+    def clear(self):
+        """
+        Removes all artifacts in the local repository folder.
+        """
+        for f in os.listdir(self.localRepositoryFolder):
+            path = "%s/%s" % (self.localRepositoryFolder, f)
+            if os.path.isfile(path):
+                os.remove(path)
+        printAndFlush("Artifact repository cleared.")
+        
+    def getPathToArtifact(self, project, pattern='.*'):
+        """
+        Returns the path to artifact requested by the specified pattern and project.
+        The pattern is a regular expression which has to match the beginning of the artifact file name.
+        The project specifies the project on CI server to download the artifact.
+        
+        An Exception is raised if non or more than one artifact matches the pattern.  
+        """
+        files = [f for f in os.listdir(self.localRepositoryFolder) if re.match(pattern, f)]
+        if len(files) > 1:
+            raise Exception("More than one artifact in '%s' matches the pattern '%s': %s" 
+                            % (self.localRepositoryFolder, pattern, files))
+        if len(files) == 0:
+            f = self.downloadArtifact(project, pattern)
+        else:
+            f = files[0]
+        return "%s/%s" % (self.localRepositoryFolder, f)
+    
+    def downloadArtifact(self, project, pattern):
+        """
+        Abstract method which needs to be implemented by subclasses.
+        """
+        pass
+        
+    def _download(self, readHandle, fileName):
+        filePath = "%s/%s" % (self.localRepositoryFolder, fileName)
+        writeHandle = open(filePath, 'wb')
+        try:
+            blockSize = 8192
+            while True:
+                dataBlock = readHandle.read(blockSize)
+                if not dataBlock:
+                    break
+                writeHandle.write(dataBlock)
+        finally:
+            writeHandle.close()
+    
+class JenkinsArtifactRepository(ArtifactRepository):
+    """
+    Artifact repository for a CI server based on Jenkins.
+    """
+    def __init__(self, baseUrl, localRepositoryFolder):
+        """
+        Creates a new instance for the specified server URL and local repository.
+        """
+        ArtifactRepository.__init__(self, localRepositoryFolder)
+        self.baseUrl = baseUrl
+        
+    def downloadArtifact(self, project, pattern):
+        """
+        Downloads the requested artifact from Jenkins. It uses the Jenkins API.
+        """
+        projectUrl = "%s/job/%s" % (self.baseUrl, project)
+        apiUrl = "%s/lastSuccessfulBuild/api/xml?xpath=//artifact&wrapper=bag" % projectUrl
+        printAndFlush("Get artifact info from %s" % apiUrl)
+        handle = urlopen(apiUrl) # urllib.urlopen(apiUrl)
+        url = None
+        fileName = None
+        dom = xml.dom.minidom.parseString(handle.read())
+        for element in dom.getElementsByTagName('artifact'):
+            elementFileName = element.getElementsByTagName('fileName')[0].firstChild.nodeValue
+            if re.match(pattern, elementFileName):
+                if fileName != None:
+                    raise Exception("Pattern '%s' matches at least two artifacts in project '%s': %s and %s" 
+                                    % (pattern, project, fileName, elementFileName))
+                fileName = elementFileName
+                relativePath = element.getElementsByTagName('relativePath')[0].firstChild.nodeValue
+                url = "%s/lastSuccessfulBuild/artifact/%s" % (projectUrl, relativePath)
+        if url == None:
+            raise Exception("For pattern '%s' no artifact found in project '%s'." % (pattern, project))
+        printAndFlush("Download %s to %s." % (url, self.localRepositoryFolder))
+        self._download(urlopen(url), fileName)
+        return fileName
+    
+class GitArtifactRepository(ArtifactRepository):
+    """
+    Artifact repository for a git projects.
+    """
+    def __init__(self, localRepositoryFolder, host = 'github.com'):
+        ArtifactRepository.__init__(self, localRepositoryFolder)
+        self.host = host
+
+    def downloadArtifact(self, project, pattern):
+        url = "https://%s/%s/archive/%s" % (self.host, project, pattern)
+        printAndFlush("Download %s to %s." % (url, self.localRepositoryFolder))
+        self._download(urlopen(url), pattern)
+        return pattern
diff --git a/api-openbis-python3-pybis/src/python/tests/systemtest/settings.py b/api-openbis-python3-pybis/src/python/tests/systemtest/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..8086cd3b3a0e21df0bc245f277032433974f4b5d
--- /dev/null
+++ b/api-openbis-python3-pybis/src/python/tests/systemtest/settings.py
@@ -0,0 +1,56 @@
+#   Copyright ETH 2013 - 2023 Zürich, Scientific IT Services
+# 
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+# 
+#        http://www.apache.org/licenses/LICENSE-2.0
+#   
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+"""
+Setup infrastructure common for all tests.
+"""
+import sys
+import os.path
+import time
+
+# Default base URL of the CI server which hosts the artifacts.
+ci_base_url = 'http://bs-ci01.ethz.ch:8090'
+
+reuseRepository = False
+devMode = False
+cmd = sys.argv[0]
+if len(sys.argv) > 1:
+    firstArgument = sys.argv[1]
+    if firstArgument == '-r':
+        reuseRepository = True
+    elif firstArgument == '-dr' or firstArgument == '-rd':
+        reuseRepository = True
+        devMode = True
+    elif firstArgument == '-d':
+        devMode = True
+    elif firstArgument == '-s':
+        ci_base_url = sys.argv[2]
+    elif firstArgument == '-h':
+        print(("Usage: %s [-h|-r|-d|-rd|-s <ci server>]\n-h: prints this help\n-r: reuses artifact repository\n"
+            + "-d: developing mode\n-rd: both options\n"
+            + "-s <ci server>: option for CI server base URL") % os.path.basename(cmd))
+        exit(1)
+    else:
+        print("Unknown option: %s. Use option '-h' to see usage." % firstArgument)
+        exit(1)
+
+dirname = os.path.dirname(os.path.abspath(__file__))
+sys.path.append("%s/source" % dirname)
+sys.path.append("%s/sourceTest" % dirname)
+
+from artifactrepository import JenkinsArtifactRepository
+
+REPOSITORY = JenkinsArtifactRepository(ci_base_url, "%s/targets/artifact-repository" % dirname)
+if not reuseRepository:
+    REPOSITORY.clear()
diff --git a/api-openbis-python3-pybis/src/python/tests/systemtest/test.py b/api-openbis-python3-pybis/src/python/tests/systemtest/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..27fc51f239724cc9260922f0fcfc51af2c55cd98
--- /dev/null
+++ b/api-openbis-python3-pybis/src/python/tests/systemtest/test.py
@@ -0,0 +1,37 @@
+#   Copyright ETH 2023 Zürich, Scientific IT Services
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+
+import os
+
+import settings
+import testcase
+
+
+class TestCase(testcase.TestCase):
+
+    def execute(self):
+
+        self.installOpenbis()
+        # self.installPybis()
+        self.openbisController = self.createOpenbisController()
+        self.openbisController.createTestDatabase("openbis")
+        self.openbisController.allUp()
+
+        os.system('pytest --junitxml=test_results_pybis.xml $WORKSPACE/api-openbis-python3-pybis/src/python/tests')
+
+
+
+
+TestCase(settings, __file__).runTest()
\ No newline at end of file
diff --git a/api-openbis-python3-pybis/src/python/tests/systemtest/testcase.py b/api-openbis-python3-pybis/src/python/tests/systemtest/testcase.py
new file mode 100644
index 0000000000000000000000000000000000000000..a84d6a55bcce98567da0c408105d847fb6182603
--- /dev/null
+++ b/api-openbis-python3-pybis/src/python/tests/systemtest/testcase.py
@@ -0,0 +1,871 @@
+#   Copyright ETH 2013 - 2023 Zürich, Scientific IT Services
+# 
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+# 
+#        http://www.apache.org/licenses/LICENSE-2.0
+#   
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+
+import difflib
+import os
+import os.path
+import re
+import shutil
+import time
+import traceback
+
+import util as util
+
+INSTALLER_PROJECT = 'app-openbis-installer'
+OPENBIS_STANDARD_TECHNOLOGIES_PROJECT = 'core-plugin-openbis'
+DATAMOVER_PROJECT = 'datamover'
+
+PSQL_EXE = 'psql'
+
+PLAYGROUND = 'targets/playground'
+TEMPLATES = 'templates'
+TEST_DATA = 'testData'
+
+DEFAULT_TIME_OUT_IN_MINUTES = 5
+
+
+class TestCase(object):
+    """
+    Abstract superclass of a test case. 
+    Subclasses have to override execute() and optionally executeInDevMode(). 
+    The test case is run by invoking runTest(). 
+    Here is a skeleton of a test case:
+    
+    #!/usr/bin/python
+    import settings
+    import systemtest.testcase
+
+    class TestCase(systemtest.testcase.TestCase):
+    
+        def execute(self):
+            ....
+            
+        def executeInDevMode(self):
+            ....
+            
+    TestCase(settings, __file__).runTest()
+
+    There are two execution modes (controlled by command line option -d and -rd):
+    
+    Normal mode: 
+        1. Cleans up playground: Kills running servers and deletes playground folder of this test case.
+        2. Invokes execute() method.
+        3. Release resources: Shuts down running servers.
+        
+    Developing mode:
+        1. Invokes executeInDevMode() method.
+        
+    The developing mode allows to reuse already installed servers. 
+    Servers might be restarted. This mode leads to fast development 
+    of test code by doing incremental development. Working code
+    can be moved from executeInDevMode() to execute(). 
+    """
+
+    def __init__(self, settings, filePath):
+        self.artifactRepository = settings.REPOSITORY
+        self.project = None
+        fileName = os.path.basename(filePath)
+        self.name = fileName[0:fileName.rfind('.')]
+        self.playgroundFolder = "%s/%s" % (PLAYGROUND, self.name)
+        self.numberOfFailures = 0
+        self.devMode = settings.devMode
+        self.runningInstances = []
+
+    def runTest(self):
+        """
+        Runs this test case. This is a final method. It should not be overwritten.
+        """
+        startTime = time.time()
+        util.printAndFlush("\n/''''''''''''''''''' %s started at %s %s ''''''''''"
+                           % (self.name, time.strftime('%Y-%m-%d %H:%M:%S'),
+                              'in DEV MODE' if self.devMode else ''))
+        try:
+            if not self.devMode:
+                if os.path.exists(self.playgroundFolder):
+                    self._cleanUpPlayground()
+                os.makedirs(self.playgroundFolder)
+                self.execute()
+            else:
+                self.executeInDevMode()
+            success = self.numberOfFailures == 0
+        except:
+            util.printAndFlush(traceback.format_exc())
+            success = False
+        finally:
+            duration = util.renderDuration(time.time() - startTime)
+            if not self.devMode:
+                self.releaseResources()
+            if success:
+                util.printAndFlush(
+                    "\...........SUCCESS: %s executed in %s .........." % (self.name, duration))
+            else:
+                util.printAndFlush(
+                    "\............FAILED: %s executed in %s .........." % (self.name, duration))
+                raise Exception("%s failed" % self.name)
+
+    def execute(self):
+        """
+        Executes this test case in normal mode. 
+        This is an abstract method which has to be overwritten in subclasses.
+        """
+        pass
+
+    def executeInDevMode(self):
+        """
+        Executes this test case in developing mode. 
+        This method can be overwritten in subclasses.
+        """
+        pass
+
+    def releaseResources(self):
+        """
+        Releases resources. It shuts down all running servers.
+        This method can be overwritten in subclasses. 
+        Note, this method can be invoked in subclasses as follows:
+        
+                super(type(self), self).releaseResources()
+        
+        """
+        self._shutdownSevers()
+
+    def assertPatternInLog(self, log, pattern):
+        if not re.search(pattern, log):
+            self.fail("Pattern doesn't match: %s" % pattern)
+
+    def assertSmaller(self, itemName, expectedUpperLimit, actualValue, verbose=True):
+        """
+        Asserts that actualValue <= expectedUpperLimit. If not the test will be continued but counted as failed.
+        Returns False if assertion fails otherwise True.
+        """
+        if actualValue > expectedUpperLimit:
+            self.fail("%s\n  actual value <%s> exceeds the expected upper limit <%s>" % (
+                itemName, actualValue, expectedUpperLimit))
+            return False
+        elif verbose:
+            util.printAndFlush("%s actual value <%s> is below the expected upper limit <%s>" % (
+                itemName, actualValue, expectedUpperLimit))
+        return True
+
+    def assertEquals(self, itemName, expected, actual, verbose=True):
+        """
+        Asserts that expected == actual. If not the test will be continued but counted as failed.
+        Returns False if assertion fails otherwise True.
+        """
+        rendered_expected = self._render(expected)
+        if expected != actual:
+            rendered_actual = self._render(actual)
+            diff = difflib.ndiff(rendered_expected.splitlines(), rendered_actual.splitlines())
+            self.fail("%s\n  Differences:\n%s" % (itemName, '\n'.join(diff)))
+            return False
+        elif verbose:
+            util.printAndFlush("%s as expected: <%s>" % (itemName, rendered_expected))
+        return True
+
+    def assertType(self, variableName, expectedType, variable):
+        self.assertEquals("Type of %s" % variableName, expectedType, type(variable))
+
+    def assertIn(self, itemsName, items, item):
+        if item not in items:
+            self.fail("Item %s not in %s" % (item, itemsName))
+        util.printAndFlush("%s as expected: contains <%s>" % (itemsName, item))
+
+    def assertNone(self, itemName, item):
+        self.assertEquals(itemName, None, item)
+
+    def assertNotNone(self, itemName, item):
+        if item is None:
+            self.fail("Item %s is None" % itemName)
+        util.printAndFlush("%s as expected: not None" % itemName)
+
+    def assertTrue(self, itemName, item):
+        self.assertEquals(itemName, True, item)
+
+    def assertFalse(self, itemName, item):
+        self.assertEquals(itemName, False, item)
+
+    def assertLength(self, itemsName, length, items):
+        self.assertEquals("Length of %s" % itemsName, length, len(items))
+
+    def assertEmpty(self, itemsName, items):
+        self.assertLength(itemsName, 0, items)
+
+    def assertNotEmpty(self, itemsName, items):
+        if len(items) == 0:
+            self.fail("%s is empty" % itemsName)
+        util.printAndFlush("%s as expected: not empty" % itemsName)
+
+    def _render(self, item):
+        if not isinstance(item, list):
+            return str(item)
+        result = ""
+        for e in item:
+            if len(result) > 0:
+                result += "\n"
+            result += str(e)
+        return result
+
+    def fail(self, errorMessage):
+        """
+        Prints specified error message and mark test case as failed.
+        """
+        self.numberOfFailures += 1
+        util.printWhoAmI(levels=10, template="ERROR found (caller chain: %s)")
+        util.printAndFlush("ERROR causing test failure: %s" % errorMessage)
+
+    def installScriptBasedServer(self, templateName, instanceName,
+                                 startCommand=['./start.sh'], stopCommand=['./stop.sh']):
+        installPath = self._getInstallPath(instanceName)
+        if os.path.exists(installPath):
+            shutil.rmtree(installPath)
+        shutil.copytree("%s/%s" % (self.getTemplatesFolder(), templateName), installPath)
+        return ScriptBasedServerController(self, self.name, installPath, instanceName, startCommand,
+                                           stopCommand)
+
+    def createScriptBasedServerController(self, instanceName, startCommand=['./start.sh'],
+                                          stopCommand=['./stop.sh']):
+        return ScriptBasedServerController(self, self.name, self._getInstallPath(instanceName),
+                                           instanceName,
+                                           startCommand, stopCommand)
+
+    def installDatamover(self, instanceName='datamover'):
+        zipFile = self.artifactRepository.getPathToArtifact(DATAMOVER_PROJECT, 'datamover')
+        installPath = self._getInstallPath(instanceName)
+        util.unzip(zipFile, self.playgroundFolder)
+        os.rename("%s/datamover" % (self.playgroundFolder), installPath)
+        return DatamoverController(self, self.name, installPath, instanceName)
+
+    def createDatamoverController(self, instanceName='datamover'):
+        return DatamoverController(self, self.name, self._getInstallPath(instanceName),
+                                   instanceName)
+
+    def installOpenbis(self, instanceName='openbis', technologies=[]):
+        """
+        Installs openBIS from the installer. 
+        
+        The instanceName specifies the subfolder in the playground folder 
+        where the instance will be installed. 
+        In addition it is also part of the database names.
+        The technologies are an array of enabled technologies.
+        """
+        installerPath = self.artifactRepository.getPathToArtifact(INSTALLER_PROJECT,
+                                                                  'openBIS-installation')
+        installerFileName = os.path.basename(installerPath).rpartition('.tar')[0]
+        util.executeCommand(['tar', '-zxf', installerPath, '-C', self.playgroundFolder],
+                            "Couldn't untar openBIS installer.")
+        consolePropertiesFile = "%s/%s/console.properties" % (
+            self.playgroundFolder, installerFileName)
+        consoleProperties = util.readProperties(consolePropertiesFile)
+        installPath = self._getInstallPath(instanceName)
+        consoleProperties['INSTALL_PATH'] = installPath
+        consoleProperties['DSS_ROOT_DIR'] = "%s/data" % installPath
+        for technology in technologies:
+            consoleProperties[technology.upper()] = True
+        util.writeProperties(consolePropertiesFile, consoleProperties)
+        util.executeCommand("%s/%s/run-console.sh" % (self.playgroundFolder, installerFileName),
+                            "Couldn't install openBIS", consoleInput='admin\nadmin')
+        shutil.rmtree("%s/%s" % (self.playgroundFolder, installerFileName))
+
+    def cloneOpenbisInstance(self, nameOfInstanceToBeCloned, nameOfNewInstance,
+                             dataStoreServerOnly=False):
+        """ Clones an openBIS instance. """
+
+        oldInstanceInstallPath = "%s/%s" % (self.playgroundFolder, nameOfInstanceToBeCloned)
+        newInstanceInstallPath = "%s/%s" % (self.playgroundFolder, nameOfNewInstance)
+        paths = ['bin', 'data', 'servers/core-plugins', 'servers/datastore_server']
+        if not dataStoreServerOnly:
+            paths.append('servers/openBIS-server')
+        for path in paths:
+            util.copyFromTo(oldInstanceInstallPath, newInstanceInstallPath, path)
+        dssPropsFile = "%s/servers/datastore_server/etc/service.properties" % newInstanceInstallPath
+        dssProps = util.readProperties(dssPropsFile)
+        dssProps['root-dir'] = dssProps['root-dir'].replace(nameOfInstanceToBeCloned,
+                                                            nameOfNewInstance)
+        util.writeProperties(dssPropsFile, dssProps)
+
+    def createOpenbisController(self, instanceName='openbis', port='8443', dropDatabases=True,
+                                databasesToDrop=[]):
+        """
+        Creates an openBIS controller object assuming that an openBIS instance for the specified name is installed.
+        """
+        return OpenbisController(self, self.name, self._getInstallPath(instanceName), instanceName,
+                                 port,
+                                 dropDatabases, databasesToDrop)
+
+    def installScreeningTestClient(self):
+        """ Installs the screening test client and returns an instance of ScreeningTestClient. """
+        zipFile = self.artifactRepository.getPathToArtifact(OPENBIS_STANDARD_TECHNOLOGIES_PROJECT,
+                                                            'openBIS-screening-API')
+        installPath = "%s/screeningAPI" % self.playgroundFolder
+        util.unzip(zipFile, installPath)
+        return ScreeningTestClient(self, installPath)
+
+    def installPybis(self):
+        # install the local pybis in editable-mode (-e)
+        util.executeCommand(['pip', 'install', '-e', '../api-openbis-python3-pybis/src/python'],
+                            "Installation of pybis failed.")
+
+    def installObis(self):
+        # install the local obis in editable-mode (-e)
+        util.executeCommand(['pip', 'install', '-e', '../app-openbis-command-line/src/python'],
+                            "Installation of obis failed.")
+
+    def getTemplatesFolder(self):
+        return "%s/%s" % (TEMPLATES, self.name)
+
+    def _getInstallPath(self, instanceName):
+        return os.path.abspath("%s/%s" % (self.playgroundFolder, instanceName))
+
+    def _cleanUpPlayground(self):
+        for f in os.listdir(self.playgroundFolder):
+            path = "%s/%s" % (self.playgroundFolder, f)
+            if not os.path.isdir(path):
+                continue
+            util.printAndFlush("clean up %s" % path)
+            util.killProcess("%s/servers/datastore_server/datastore_server.pid" % path)
+            util.killProcess("%s/servers/openBIS-server/jetty/openbis.pid" % path)
+            util.killProcess("%s/datamover.pid" % path)
+        util.deleteFolder(self.playgroundFolder)
+
+    def _addToRunningInstances(self, controller):
+        self.runningInstances.append(controller)
+
+    def _removeFromRunningInstances(self, controller):
+        if controller in self.runningInstances:
+            self.runningInstances.remove(controller)
+
+    def _shutdownSevers(self):
+        for instance in reversed(self.runningInstances):
+            instance.stop()
+
+
+class _Controller(object):
+    def __init__(self, testCase, testName, installPath, instanceName):
+        self.testCase = testCase
+        self.testName = testName
+        self.instanceName = instanceName
+        self.installPath = installPath
+        util.printAndFlush("Controller created for instance '%s'. Installation path: %s" % (
+            instanceName, installPath))
+
+    def createFolder(self, folderPath):
+        """
+        Creates a folder with specified path relative to installation directory.
+        """
+        path = "%s/%s" % (self.installPath, folderPath)
+        os.makedirs(path)
+
+    def assertEmptyFolder(self, pathRelativeToInstallPath):
+        """
+        Asserts that the specified path (relative to the installation path) is an empty folder.
+        """
+        relativePath = "%s/%s" % (self.installPath, pathRelativeToInstallPath)
+        files = self._getFiles(relativePath)
+        if len(files) == 0:
+            util.printAndFlush("Empty folder as expected: %s" % relativePath)
+        else:
+            self.testCase.fail(
+                "%s isn't empty. It contains the following files:\n  %s" % (relativePath, files))
+
+    def assertFiles(self, folderPathRelativeToInstallPath, expectedFiles):
+        """
+        Asserts that the specified path (relative to the installation path) contains the specified files.
+        """
+        relativePath = "%s/%s" % (self.installPath, folderPathRelativeToInstallPath)
+        files = self._getFiles(relativePath)
+        self.testCase.assertEquals("Files in %s" % relativePath, expectedFiles, sorted(files))
+
+    def _getFiles(self, relativePath):
+        if not os.path.isdir(relativePath):
+            self.testCase.fail("Doesn't exist or isn't a folder: %s" % relativePath)
+        files = os.listdir(relativePath)
+        return files
+
+
+class ScriptBasedServerController(_Controller):
+    def __init__(self, testCase, testName, installPath, instanceName, startCommand, stopCommand):
+        super(ScriptBasedServerController, self).__init__(testCase, testName, installPath,
+                                                          instanceName)
+        self.startCommand = startCommand
+        self.stopCommand = stopCommand
+
+    def start(self):
+        self.testCase._addToRunningInstances(self)
+        util.executeCommand(self.startCommand, "Couldn't start server '%s'" % self.instanceName,
+                            workingDir=self.installPath)
+
+    def stop(self):
+        self.testCase._removeFromRunningInstances(self)
+        util.executeCommand(self.stopCommand, "Couldn't stop server '%s'" % self.instanceName,
+                            workingDir=self.installPath)
+
+
+class DatamoverController(_Controller):
+    def __init__(self, testCase, testName, installPath, instanceName):
+        super(DatamoverController, self).__init__(testCase, testName, installPath, instanceName)
+        self.servicePropertiesFile = "%s/etc/service.properties" % self.installPath
+        self.serviceProperties = util.readProperties(self.servicePropertiesFile)
+        self.serviceProperties['check-interval'] = 2
+        self.serviceProperties['quiet-period'] = 5
+        self.serviceProperties['inactivity-period'] = 15
+        dataCompletedScript = "%s/%s/data-completed.sh" % (
+            testCase.getTemplatesFolder(), instanceName)
+        if os.path.exists(dataCompletedScript):
+            self.serviceProperties['data-completed-script'] = "../../../../%s" % dataCompletedScript
+
+    def setPrefixForIncoming(self, prefix):
+        """ Set service property 'prefix-for-incoming'. """
+        self.serviceProperties['prefix-for-incoming'] = prefix
+
+    def setTreatIncomingAsRemote(self, flag):
+        """ Set service property 'treat-incoming-as-remote'. """
+        self.serviceProperties['treat-incoming-as-remote'] = flag
+
+    def setOutgoingTarget(self, path):
+        """ 
+        Set service property 'outgoing-target'. 
+        This has to be a path relative to installation path of the datamover. 
+        """
+        self.serviceProperties['outgoing-target'] = path
+
+    def setExtraCopyDir(self, path):
+        """ 
+        Set service property 'extra-copy-dir'. 
+        This has to be a path relative to installation path of the datamover. 
+        """
+        self.serviceProperties['extra-copy-dir'] = path
+
+    def start(self):
+        """ Starts up datamover server. """
+        util.writeProperties(self.servicePropertiesFile, self.serviceProperties)
+        self.testCase._addToRunningInstances(self)
+        output = util.executeCommand(["%s/datamover.sh" % self.installPath, 'start'],
+                                     suppressStdOut=True)
+        joinedOutput = '\n'.join(output)
+        if 'FAILED' in joinedOutput:
+            util.printAndFlush(
+                "Start up of datamover %s failed:\n%s" % (self.instanceName, joinedOutput))
+            raise Exception("Couldn't start up datamover '%s'." % self.instanceName)
+
+    def stop(self):
+        """ Stops datamover server. """
+        self.testCase._removeFromRunningInstances(self)
+        util.executeCommand(["%s/datamover.sh" % self.installPath, 'stop'],
+                            "Couldn't shut down datamover '%s'." % self.instanceName)
+
+    def drop(self, testDataSetName):
+        """ Drops the specified test data set into incoming folder. """
+        util.copyFromTo("%s/%s" % (TEST_DATA, self.testName), "%s/data/incoming" % self.installPath,
+                        testDataSetName)
+
+
+class ScreeningTestClient(object):
+    """
+    Class representing the screeing test client.
+    """
+
+    def __init__(self, testCase, installPath):
+        self.testCase = testCase
+        self.installPath = installPath
+
+    def run(self):
+        """ Runs the test client and returns the console output as a list of strings. """
+        output = util.executeCommand(['java',
+                                      '-Djavax.net.ssl.trustStore=../openbis/servers/openBIS-server/jetty/etc/openBIS.keystore',
+                                      '-jar', 'openbis_screening_api.jar', 'admin', 'admin',
+                                      'https://localhost:8443'], suppressStdOut=True,
+                                     workingDir=self.installPath)
+        with open("%s/log.txt" % self.installPath, 'w') as log:
+            for line in output:
+                log.write("%s\n" % line)
+        return output
+
+
+class DataSet(object):
+    def __init__(self, resultSetRow):
+        self.id = resultSetRow[0]
+        self.dataStore = resultSetRow[1]
+        self.experimentCode = resultSetRow[2]
+        self.code = resultSetRow[3]
+        self.type = resultSetRow[4]
+        self.location = resultSetRow[5]
+        self.status = resultSetRow[6]
+        self.presentInArchive = resultSetRow[7]
+        self.producer = resultSetRow[8]
+        self.productionTimeStamp = resultSetRow[9]
+        self.parents = []
+        self.children = []
+
+    def __str__(self):
+        parents = [d.id for d in self.parents]
+        children = [d.id for d in self.children]
+        return "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s" % (
+            self.id, self.dataStore, self.code, self.type,
+            self.location, self.status, self.presentInArchive,
+            parents, children, self.experimentCode,
+            self.producer, self.productionTimeStamp)
+
+
+class OpenbisController(_Controller):
+    """
+    Class to control AS and DSS of an installed openBIS instance.
+    """
+
+    def __init__(self, testCase, testName, installPath, instanceName, port='8443',
+                 dropDatabases=True, databasesToDrop=[]):
+        """
+        Creates a new instance for specifies test case with specified test and instance name, installation path.
+        """
+        super(OpenbisController, self).__init__(testCase, testName, installPath, instanceName)
+        self.templatesFolder = testCase.getTemplatesFolder()
+        self.binFolder = "%s/bin" % installPath
+        self.bisUpScript = "%s/bisup.sh" % self.binFolder
+        self.bisDownScript = "%s/bisdown.sh" % self.binFolder
+        self.dssUpScript = "%s/dssup.sh" % self.binFolder
+        self.dssDownScript = "%s/dssdown.sh" % self.binFolder
+        self.databaseKind = "%s_%s" % (testName, instanceName)
+        self.asServicePropertiesFile = "%s/servers/openBIS-server/jetty/etc/service.properties" % installPath
+        self.asProperties = None
+        if os.path.exists(self.asServicePropertiesFile):
+            self.asProperties = util.readProperties(self.asServicePropertiesFile)
+            self.asProperties['database.kind'] = self.databaseKind
+            self.asPropertiesModified = True
+        self.dssServicePropertiesFile = "%s/servers/datastore_server/etc/service.properties" % installPath
+        self.dssProperties = util.readProperties(self.dssServicePropertiesFile)
+        self.dssProperties['path-info-db.databaseKind'] = self.databaseKind
+        self.dssProperties['imaging-database.kind'] = self.databaseKind
+        self.dssPropertiesModified = True
+        self.passwdScript = "%s/servers/openBIS-server/jetty/bin/passwd.sh" % installPath
+        if port != '8443':
+            self.sslIniFile = "%s/servers/openBIS-server/jetty/start.d/ssl.ini" % installPath
+            if os.path.exists(self.sslIniFile):
+                self.sslIni = util.readProperties(self.sslIniFile)
+                self.sslIni['jetty.ssl.port'] = port
+                util.writeProperties(self.sslIniFile, self.sslIni)
+        if dropDatabases:
+            util.dropDatabase(PSQL_EXE, "openbis_%s" % self.databaseKind)
+            util.dropDatabase(PSQL_EXE, "pathinfo_%s" % self.databaseKind)
+            util.dropDatabase(PSQL_EXE, "imaging_%s" % self.databaseKind)
+            self._setUpStore()
+            self._setUpFileServer()
+        for databaseToDrop in databasesToDrop:
+            util.dropDatabase(PSQL_EXE, "%s_%s" % (databaseToDrop, self.databaseKind))
+        self._applyCorePlugins()
+
+    def setDummyAuthentication(self):
+        """ Disables authentication. """
+        self.asProperties['authentication-service'] = 'dummy-authentication-service'
+
+    def setOpenbisPortDataStoreServer(self, port):
+        as_url = self.dssProperties['server-url']
+        util.printAndFlush('as_url' + as_url)
+        parts = as_url.split(':')
+        s = ""
+        for idx, part in enumerate(parts):
+            if (idx < len(parts) - 1):
+                s = s + part + ":"
+        self.dssProperties['server-url'] = s + port
+
+    def setDataStoreServerCode(self, code):
+        """ Sets the code of the Data Store Server. """
+        self.dssProperties['data-store-server-code'] = code
+
+    def getDataStoreServerCode(self):
+        return self.dssProperties['data-store-server-code']
+
+    def setDataStoreServerPort(self, port):
+        """ Sets the port of the Data Store Server. """
+        self.dssProperties['port'] = port
+
+    def setDataStoreServerUsername(self, username):
+        """ Sets the username of the Data Store Server. """
+        self.dssProperties['username'] = username
+
+    def setDataStoreServerProperty(self, prop, val):
+        """ Can be used to set the value of any property in DSS service.properties """
+        self.dssProperties[prop] = val
+
+    def setAsMaxHeapSize(self, maxHeapSize):
+        self._setMaxHeapSize("openBIS-server/jetty/etc/openbis.conf", maxHeapSize)
+
+    def setDssMaxHeapSize(self, maxHeapSize):
+        self._setMaxHeapSize("datastore_server/etc/datastore_server.conf", maxHeapSize)
+
+    def enableProjectSamples(self):
+        self.asProperties['project-samples-enabled'] = "true"
+
+    def assertFileExist(self, pathRelativeToInstallPath):
+        """
+        Asserts that the specified path (relative to the installation path) exists.
+        """
+        relativePath = "%s/%s" % (self.installPath, pathRelativeToInstallPath)
+        if os.path.exists(relativePath):
+            util.printAndFlush("Path exists as expected: %s" % relativePath)
+        else:
+            self.testCase.fail("Path doesn't exist: %s" % relativePath)
+
+    def assertDataSetContent(self, pathToOriginal, dataSet):
+        path = "%s/data/store/1/%s/original" % (self.installPath, dataSet.location)
+        path = "%s/%s" % (path, os.listdir(path)[0])
+        numberOfDifferences = util.getNumberOfDifferences(pathToOriginal, path)
+        if numberOfDifferences > 0:
+            self.testCase.fail("%s differences found." % numberOfDifferences)
+
+    def assertNumberOfDataSets(self, expectedNumberOfDataSets, dataSets):
+        """
+        Asserts that the specified number of data sets from the specified list of DataSet instances 
+        are in the data store. 
+        """
+        count = 0
+        for dataSet in dataSets:
+            if dataSet.dataStore != self.getDataStoreServerCode() or dataSet.location == '':
+                continue
+            count += 1
+            self.assertFileExist("data/store/1/%s" % dataSet.location)
+        self.testCase.assertEquals(
+            "Number of data sets in data store %s" % self.getDataStoreServerCode(),
+            expectedNumberOfDataSets, count)
+
+    def storeDirectory(self):
+        """
+        Return the path to the data/store directory
+        """
+        return "data/store"
+
+    def getDataSets(self):
+        """
+        Returns all data sets as a list (ordered by data set ids) of instances of class DataSet.
+        """
+        resultSet = self.queryDatabase('openbis',
+                                       "select data.id,ds.code,e.code,data.code,t.code,location,status,present_in_archive,"
+                                       + "    data.data_producer_code,data.production_timestamp from data"
+                                       + " left join external_data as ed on ed.id = data.id"
+                                       + " join data_set_types as t on data.dsty_id = t.id"
+                                       + " join experiments as e on data.expe_id = e.id"
+                                       + " join data_stores as ds on data.dast_id = ds.id order by data.id")
+        dataSets = []
+        dataSetsById = {}
+        for row in resultSet:
+            dataSet = DataSet(row)
+            dataSets.append(dataSet)
+            dataSetsById[dataSet.id] = dataSet
+        relationships = self.queryDatabase('openbis',
+                                           "select data_id_parent, data_id_child from data_set_relationships"
+                                           + " order by data_id_parent, data_id_child")
+        for parent_id, child_id in relationships:
+            parent = dataSetsById[parent_id]
+            child = dataSetsById[child_id]
+            parent.children.append(child)
+            child.parents.append(parent)
+        util.printAndFlush(
+            "All data sets:\nid,dataStore,code,type,location,status,presentInArchive,parents,children,experiment,producer,productionTimeStamp")
+        for dataSet in dataSets:
+            util.printAndFlush(dataSet)
+        return dataSets
+
+    def createTestDatabase(self, databaseType):
+        """
+        Creates a test database for the specified database type.
+        """
+        database = "%s_%s" % (databaseType, self.databaseKind)
+        scriptPath = "%s/%s.sql" % (self.templatesFolder, database)
+        util.createDatabase(PSQL_EXE, database, scriptPath)
+
+    def dropDatabase(self, databaseType):
+        """
+        Drops the database for the specified database type.
+        """
+        util.dropDatabase(PSQL_EXE, "%s_%s" % (databaseType, self.databaseKind))
+
+    def queryDatabase(self, databaseType, queryStatement, showHeaders=False):
+        """
+        Executes the specified SQL statement for the specified database type. Result set is returned
+        as a list of lists.
+        """
+        database = "%s_%s" % (databaseType, self.databaseKind)
+        return util.queryDatabase(PSQL_EXE, database, queryStatement, showHeaders)
+
+    def allUp(self):
+        """ Starts up AS and DSS if not running. """
+        if not util.isAlive("%s/servers/openBIS-server/jetty/openbis.pid" % self.installPath,
+                            "openBIS.keystore"):
+            self._saveAsPropertiesIfModified()
+            util.executeCommand([self.bisUpScript],
+                                "Starting up openBIS AS '%s' failed." % self.instanceName)
+        self.dssUp()
+
+    def stop(self):
+        self.allDown()
+
+    def allDown(self):
+        """ Shuts down AS and DSS. """
+        self.testCase._removeFromRunningInstances(self)
+        util.executeCommand([self.dssDownScript],
+                            "Shutting down openBIS DSS '%s' failed." % self.instanceName)
+        if self.asProperties:
+            util.executeCommand([self.bisDownScript],
+                                "Shutting down openBIS AS '%s' failed." % self.instanceName)
+
+    def dssUp(self):
+        """ Starts up DSS if not running. """
+        if not util.isAlive("%s/servers/datastore_server/datastore_server.pid" % self.installPath,
+                            "openBIS.keystore"):
+            self._saveDssPropertiesIfModified()
+            self.testCase._addToRunningInstances(self)
+            util.executeCommand([self.dssUpScript],
+                                "Starting up openBIS DSS '%s' failed." % self.instanceName)
+
+    def dssDown(self):
+        """ Shuts down DSS. """
+        self.testCase._removeFromRunningInstances(self)
+        util.executeCommand([self.dssDownScript],
+                            "Shutting down openBIS DSS '%s' failed." % self.instanceName)
+
+    def dropAndWait(self, dataName, dropBoxName, numberOfDataSets=1,
+                    timeOutInMinutes=DEFAULT_TIME_OUT_IN_MINUTES):
+        """
+        Drops the specified data into the specified drop box. The data is either a folder or a ZIP file
+        in TEST_DATA/<test name>. A ZIP file will be unpacked in the drop box. After dropping the method waits
+        until the specified number of data sets have been registered.
+        """
+        self.drop(dataName, dropBoxName)
+        self.waitUntilDataSetRegistrationFinished(numberOfDataSets=numberOfDataSets,
+                                                  timeOutInMinutes=timeOutInMinutes)
+
+    def dataFile(self, dataName):
+        """
+        Returns the path to the given test data
+        """
+        return "%s/%s/%s" % (TEST_DATA, self.testName, dataName)
+
+    def drop(self, dataName, dropBoxName):
+        """
+        Drops the specified test data into the specified drop box. The test data is either a folder or a ZIP file
+        in TEST_DATA/<test name>. A ZIP file will be unpacked in the drop box. 
+        """
+        destination = "%s/data/%s" % (self.installPath, dropBoxName)
+        self.dropIntoDestination(dataName, destination)
+
+    def dropIntoDestination(self, dataName, destination):
+        """
+        Drops the specified test data into the destination. The test data is either a folder or a ZIP file
+        in TEST_DATA/<test name>. A ZIP file will be unpacked in the drop box. 
+        """
+        testDataFolder = "%s/%s" % (TEST_DATA, self.testName)
+        if dataName.endswith('.zip'):
+            util.unzip("%s/%s" % (testDataFolder, dataName), destination)
+        else:
+            util.copyFromTo(testDataFolder, destination, dataName)
+
+    def waitUntilDataSetRegistrationFinished(self, numberOfDataSets=1,
+                                             timeOutInMinutes=DEFAULT_TIME_OUT_IN_MINUTES):
+        """ Waits until the specified number of data sets have been registrated. """
+        monitor = self.createLogMonior(timeOutInMinutes)
+        monitor.addNotificationCondition(util.RegexCondition('Incoming Data Monitor'))
+        monitor.addNotificationCondition(util.RegexCondition('post-registration'))
+        numberOfRegisteredDataSets = 0
+        while numberOfRegisteredDataSets < numberOfDataSets:
+            condition1 = util.RegexCondition('Post registration of (\\d*). of \\1 data sets')
+            condition2 = util.RegexCondition(
+                'Paths inside data set .* successfully added to database')
+            elements = monitor.waitUntilEvent(util.ConditionSequence([condition1, condition2]))
+            numberOfRegisteredDataSets += int(elements[0][0])
+            util.printAndFlush(
+                "%d of %d data sets registered" % (numberOfRegisteredDataSets, numberOfDataSets))
+
+    def waitUntilDataSetRegistrationFailed(self, timeOutInMinutes=DEFAULT_TIME_OUT_IN_MINUTES):
+        """ Waits until data set registration failed. """
+        self.waitUntilConditionMatched(util.EventTypeCondition('ERROR'), timeOutInMinutes)
+        util.printAndFlush("Data set registration failed as expected.")
+
+    def waitUntilConditionMatched(self, condition, timeOutInMinutes=DEFAULT_TIME_OUT_IN_MINUTES):
+        """
+        Waits until specified condition has been detected in DSS log.
+        """
+        monitor = self.createLogMonior(timeOutInMinutes)
+        monitor.addNotificationCondition(util.RegexCondition('Incoming Data Monitor'))
+        monitor.addNotificationCondition(util.RegexCondition('post-registration'))
+        monitor.waitUntilEvent(condition)
+
+    def createLogMonior(self, timeOutInMinutes=DEFAULT_TIME_OUT_IN_MINUTES):
+        logFilePath = "%s/servers/datastore_server/log/datastore_server_log.txt" % self.installPath
+        return util.LogMonitor("%s.DSS" % self.instanceName, logFilePath, timeOutInMinutes)
+
+    def assertFeatureVectorLabel(self, featureCode, expectedFeatureLabel):
+        data = self.queryDatabase('imaging',
+                                  "select distinct label from feature_defs where code = '%s'" % featureCode)
+        self.testCase.assertEquals("label of feature %s" % featureCode, [[expectedFeatureLabel]],
+                                   data)
+
+    def _applyCorePlugins(self):
+        source = "%s/core-plugins/%s" % (self.templatesFolder, self.instanceName)
+        if os.path.exists(source):
+            corePluginsFolder = "%s/servers/core-plugins" % self.installPath
+            destination = "%s/%s" % (corePluginsFolder, self.instanceName)
+            shutil.rmtree(destination, ignore_errors=True)
+            shutil.copytree(source, destination)
+            self.enableCorePlugin(self.instanceName)
+
+    def enableCorePlugin(self, pluginName):
+        corePluginsFolder = "%s/servers/core-plugins" % self.installPath
+        corePluginsPropertiesFile = "%s/core-plugins.properties" % corePluginsFolder
+        corePluginsProperties = util.readProperties(corePluginsPropertiesFile)
+        enabledModules = corePluginsProperties['enabled-modules']
+        enabledModules = "%s, %s" % (enabledModules, pluginName) if len(
+            enabledModules) > 0 else pluginName
+        corePluginsProperties['enabled-modules'] = enabledModules
+        util.writeProperties(corePluginsPropertiesFile, corePluginsProperties)
+
+    def addUser(self, name, password):
+        util.executeCommand([self.passwdScript, 'add', name, '-p', password],
+                            "Could not add user '%s' to instance '%s'." % (name, self.instanceName))
+
+    def _setUpStore(self):
+        templateStore = "%s/stores/%s" % (self.templatesFolder, self.instanceName)
+        if os.path.isdir(templateStore):
+            storeFolder = "%s/data/store" % self.installPath
+            util.printAndFlush("Set up initial data store by copying content of %s to %s" % (
+                templateStore, storeFolder))
+            shutil.rmtree(storeFolder, ignore_errors=True)
+            shutil.copytree(templateStore, storeFolder)
+
+    def _setUpFileServer(self):
+        templateFileServer = "%s/file-servers/%s" % (self.templatesFolder, self.instanceName)
+        if os.path.isdir(templateFileServer):
+            fileServiceFolder = "%s/data/file-server" % self.installPath
+            util.printAndFlush("Set up initial file server by copying content of %s to %s" % (
+                templateFileServer, fileServiceFolder))
+            shutil.rmtree(fileServiceFolder, ignore_errors=True)
+            shutil.copytree(templateFileServer, fileServiceFolder)
+
+    def _saveAsPropertiesIfModified(self):
+        if self.asPropertiesModified:
+            util.writeProperties(self.asServicePropertiesFile, self.asProperties)
+            self.asPropertiesModified = False
+
+    def _saveDssPropertiesIfModified(self):
+        if self.dssPropertiesModified:
+            util.writeProperties(self.dssServicePropertiesFile, self.dssProperties)
+            self.dssPropertiesModified = False
+
+    def _setMaxHeapSize(self, configFile, maxHeapSize):
+        path = "%s/servers/%s" % (self.installPath, configFile)
+        lines = []
+        for line in util.getContent(path):
+            if line.strip().startswith('JAVA_MEM_OPTS'):
+                line = re.sub(r'(.*)-Xmx[^ ]+(.*)', r"\1-Xmx%s\2" % maxHeapSize, line)
+            lines.append(line)
+        with open(path, "w") as f:
+            for line in lines:
+                f.write("%s\n" % line)
diff --git a/api-openbis-python3-pybis/src/python/tests/systemtest/util.py b/api-openbis-python3-pybis/src/python/tests/systemtest/util.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1e0c4305ceea13a839f109be27c2251a9d94278
--- /dev/null
+++ b/api-openbis-python3-pybis/src/python/tests/systemtest/util.py
@@ -0,0 +1,412 @@
+#   Copyright ETH 2013 - 2023 Zürich, Scientific IT Services
+# 
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+# 
+#        http://www.apache.org/licenses/LICENSE-2.0
+#   
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+import filecmp
+import inspect
+import os
+import os.path
+import re
+import shutil
+import subprocess
+import sys
+import time
+import zipfile
+
+USER=os.environ['USER']
+DEFAULT_WHO_AM_I_TEMPLATE="""
+
+/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\
+\\/\\/\/ %s
+/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\
+
+"""
+
+def printWhoAmI(levels = 1, template = DEFAULT_WHO_AM_I_TEMPLATE):
+    """
+    Prints the names of the functions in the caller chain of this function up to the specified number of levels.
+    """
+    stack = inspect.stack()
+    chain = ''
+    for i in range(1, min(levels + 1, len(stack))):
+        stack_entry = stack[i]
+        location = "%s:%s" % (stack_entry[3], stack_entry[2])
+        chain = "%s > %s" % (location, chain) if chain != '' else location
+    printAndFlush(template % chain)
+
+def printAndFlush(data):
+    """ 
+    Prints argument onto the standard console and flushes output.
+    This is necessary to get Python output and bash output in sync on CI server.
+    """
+    print(data)
+    sys.stdout.flush()
+    
+
+def readProperties(propertiesFile):
+    """
+    Reads a Java properties file and returns the key-value pairs as a dictionary.
+    """
+    with open(propertiesFile, "r") as f:
+        result = {}
+        for line in f.readlines():
+            trimmedLine = line.strip()
+            if len(trimmedLine) > 0 and not trimmedLine.startswith('#'):
+                splittedLine = line.split('=', 1)
+                key = splittedLine[0].strip()
+                value = splittedLine[1].strip()
+                result[key] = value
+        return result
+    
+def writeProperties(propertiesFile, dictionary):
+    """
+    Saves the specified dictionary as a Java properties file.
+    """
+    with open(propertiesFile, "w") as f:
+        for key in sorted(dictionary):
+            f.write("%s=%s\n" % (key, dictionary[key]))
+            
+def executeCommand(commandWithArguments, failingMessage = None, consoleInput = None, suppressStdOut = False, 
+                   workingDir = None):
+    """
+    Executes specified command with arguments. 
+    If the exit value of the command is not zero and a failing message has been specified 
+    an exception with the failing message will be thrown. 
+    Optionally a string for console input can be specified.
+    If flag suppressStdOut is set standard output will be suppressed but returned as a list of output lines.
+    If workingDir is specified a change to workingDir is done for execution.
+    """
+    printAndFlush("\n------- START: %s" % commandWithArguments)
+    currentDir = None
+    if workingDir != None:
+        printAndFlush("change to working directory '%s'" % workingDir)
+        currentDir = os.getcwd()
+        os.chdir(workingDir)
+    try:
+        processIn = subprocess.PIPE if consoleInput != None else None
+        processOut = subprocess.PIPE if suppressStdOut else None
+        # Setting the time zone is needed for sprint server otherwise Java log files have wrong time zone
+        os.environ['TZ'] = time.tzname[0]
+        p = subprocess.Popen(commandWithArguments, stdin = processIn, stdout = processOut, encoding='utf8')
+        if consoleInput != None:
+            p.communicate(consoleInput)
+        lines = []
+        if suppressStdOut:
+            for line in iter(p.stdout.readline,''):
+                lines.append(line.strip())
+        exitValue = p.wait()
+        if currentDir != None:
+            printAndFlush("change back to previous working directory '%s'" % currentDir)
+        if exitValue != 0 and failingMessage != None: 
+            printAndFlush("---- FAILED %d: %s" % (exitValue, commandWithArguments))
+            raise Exception(failingMessage)
+        printAndFlush("---- FINISHED: %s" % commandWithArguments)
+        return lines
+    finally:
+        if currentDir != None:
+            os.chdir(currentDir)
+            
+        
+    
+def killProcess(pidFile):
+    """
+    Kills the process in specified PID file. Does nothing if PID file doesn't exist.
+    """
+    pid = getPid(pidFile)
+    if pid is None:
+        return
+    executeCommand(['kill', pid])
+    
+def isAlive(pidFile, pattern):
+    """
+    Checks if the process with PID in specified file is alive. The specified regex
+    is used to check that the process of expected PID is the process expected.
+    """
+    pid = getPid(pidFile)
+    if pid is None:
+        return False
+    lines = executeCommand(['ps', '-p', pid], suppressStdOut=True)
+    if len(lines) < 2:
+        return False
+    return re.compile(pattern).search(lines[1]) is not None
+    
+    
+def getPid(pidFile):
+    if not os.path.exists(pidFile):
+        return None
+    return readFirstLine(pidFile)
+
+def readFirstLine(textFile):
+    """
+    Returns the first line of the specified textFile.
+    """
+    with open(textFile, 'r') as handle:
+        return handle.readline().rstrip()
+        
+def unzip(zipFile, destination):
+    """
+    Unzips specified ZIP file at specified destination.
+    """
+    executeCommand(['unzip', '-q', '-o', zipFile, '-d', destination], "Couldn't unzip %s at %s" % (zipFile, destination))
+    
+def unzipSubfolder(zipFile, subfolder, destination):
+    """
+    Unzips the specified subtree from the specified ZIP file into the specified destination
+    """
+    zf = zipfile.ZipFile(zipFile)
+    parent, name = os.path.split(subfolder)
+    if name == '': 
+        parent = os.path.dirname(parent)
+    for entry in zf.namelist():
+        if entry.startswith(subfolder):
+            newPath = entry.replace(parent, destination)
+            newPathParent = os.path.dirname(newPath)
+            if not os.path.exists(newPathParent):
+                os.makedirs(newPathParent)
+            if not newPath.endswith('/'):
+                data = zf.read(entry)
+                with open(newPath, 'wb') as out:
+                    out.write(data)
+    
+def deleteFolder(folderPath):
+    """
+    Deletes the specified folder.
+    Raises an exception in case of error.
+    """
+    printAndFlush("Delete '%s'" % folderPath)
+    def errorHandler(*args):
+        _, path, _ = args
+        raise Exception("Couldn't delete '%s'" % path)
+    shutil.rmtree(folderPath, onerror = errorHandler)
+    
+def copyFromTo(sourceFolder, destinationFolder, relativePathInSourceFolder):
+    source = "%s/%s" % (sourceFolder, relativePathInSourceFolder)
+    destination = "%s/%s" % (destinationFolder, relativePathInSourceFolder)
+    if os.path.isfile(source):
+        shutil.copyfile(source, destination)
+    else:
+        shutil.copytree(source, 
+                    destination, ignore = shutil.ignore_patterns(".*"))
+    printAndFlush("'%s' copied from '%s' to '%s'" % (relativePathInSourceFolder, sourceFolder, destinationFolder))
+
+def getDatabaseHost():
+    host = os.environ.get('FORCE_OPENBIS_POSTGRES_HOST')
+    if (host is None):
+        host = "localhost"
+    return host
+
+def dropDatabase(psqlExe, database):
+    """
+    Drops the specified database by using the specified path to psql.
+    """
+    executeCommand([psqlExe,
+    '-h', getDatabaseHost(),
+    '-U', 'postgres', '-c' , "drop database if exists %s" % database],
+                   "Couldn't drop database %s" % database)
+    
+def createDatabase(psqlExe, database, scriptPath = None):
+    """
+    Creates specified database and run (if defined) the specified SQL script. 
+    """
+    executeCommand([psqlExe,
+    '-h', getDatabaseHost(),
+    '-U', 'postgres', '-c' , "create database %s with owner %s" % (database, USER)],
+                   "Couldn't create database %s" % database)
+    if scriptPath == None:
+        return
+    executeCommand([psqlExe,
+    '-h', getDatabaseHost(),
+    '-q', '-U', USER, '-d', database,  '-f', scriptPath], suppressStdOut=True,
+                   failingMessage="Couldn't execute script %s for database %s" % (scriptPath, database))
+    
+def queryDatabase(psqlExe, database, queryStatement, showHeaders = False):
+    """
+    Queries specified database by applying specified SQL statement and returns the result set as a list
+    where each row is a list, too.
+    """
+    printingOption = '-A' if showHeaders else '-tA'
+    lines = executeCommand([psqlExe,
+    '-h', getDatabaseHost(),
+    '-U', 'postgres', printingOption, '-d', database, '-c', queryStatement],
+                           "Couldn't execute query: %s" % queryStatement, suppressStdOut = True)
+    result = []
+    for line in lines:
+        result.append(line.split('|'))
+    return result
+
+def printResultSet(resultSet):
+    """
+    Prints the specified result set.
+    """
+    for row in resultSet:
+        printAndFlush(row)
+
+def getNumberOfDifferences(fileOrFolder1, fileOrFolder2):
+    """
+    Gets and reports differences in file system structures between both arguments.
+    """
+    result = filecmp.dircmp(fileOrFolder1, fileOrFolder2, ignore=['.svn'])
+    result.report()
+    return len(result.left_only) + len(result.right_only) + len(result.diff_files)
+
+def getContent(path):
+    """
+    Returns the content at specified path as an array of lines. Trailing white spaces (including new line)
+    has been stripped off.
+    """
+    with open(path, "r") as f:
+        return [ l.rstrip() for l in f.readlines()]
+    
+def renderDuration(duration):
+    renderedDuration = renderNumber(duration, 'second')
+    if duration > 80:
+        minutes = duration / 60
+        seconds = duration % 60
+        if seconds > 0:
+            renderedDuration = "%s and %s" % (renderNumber(minutes, 'minute'), renderNumber(seconds, 'second'))
+        else:
+            renderedDuration = renderNumber(minutes, 'minute')
+    return renderedDuration
+
+def renderNumber(number, unit):
+    return ("1 %s" % unit) if number == 1 else ("%d %ss" % (number, unit))
+
+    
+class LogMonitor():
+    """
+    Monitor of a log file. Conditions can be specified for printing a notification and waiting. 
+    
+    A condition has to be a class with method 'match' which has two string arguments: 
+    Event type and log message. It returns 'None' in case of no match and 
+    a tuple with zero or more matching elements found in log message.
+    """
+    def __init__(self, logName, logFilePath, timeOutInMinutes = 5):
+        """
+        Creates an instance with specified log name (used in notification), log file, and time out.
+        """
+        self.logName = logName
+        self.logFilePath = logFilePath
+        self.timeOutInMinutes = timeOutInMinutes
+        self.conditions = []
+        self.timeProvider = time
+        class SystemPrinter:
+            def printMsg(self, msg):
+                printAndFlush(msg)
+        self.printer = SystemPrinter()
+    
+    def addNotificationCondition(self, condition):
+        """
+        Adds a notification condition
+        """
+        self.conditions.append(condition)
+
+    def getFormattedTime(self, timeSec):
+        if timeSec is None:
+            self.printer.printMsg("Error: Provided time is None!")
+        return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timeSec))
+
+
+    def waitUntilEvent(self, condition, startTime = None, delay = 0):
+        """
+        Waits until an event matches the specified condition. 
+        Returns tuple with zero or more elements of matching log message.
+        """
+        startTime = self.timeProvider.time() if startTime is None else startTime
+        self.conditions.append(condition)
+        renderedStartTime = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(startTime))
+        self.printer.printMsg("\n>>>>> Start monitoring %s log at %s >>>>>>>>>>>>>>>>>>>>" 
+                              % (self.logName, renderedStartTime))
+        finalTime = startTime + self.timeOutInMinutes * 60
+        if delay > 0:
+            time.sleep(delay)
+        try:
+            alreadyPrintedLines = set()
+            while True:
+                log = open(self.logFilePath, 'r')
+                while True:
+                    actualTime = self.timeProvider.time()
+                    if actualTime > finalTime:
+                        self.printer.printMsg(f"Time out detected! start time: {renderedStartTime}, calculated end time: {self.getFormattedTime(finalTime)}, current time: {self.getFormattedTime(actualTime)}, timeout (min): {self.timeOutInMinutes}")
+                        raise Exception("Time out after %d minutes for monitoring %s log." 
+                                        % (self.timeOutInMinutes, self.logName))
+                    line = log.readline()
+                    if line == '':
+                        break
+                    match = re.match('(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),(\d{3}) (.{6})(.*)', line)
+                    if match == None:
+                        continue
+                    timestamp = match.group(1)
+                    milliseconds = int(match.group(2))
+                    eventType = match.group(3).strip()
+                    message = match.group(4)
+                    eventTime = time.mktime(time.strptime(timestamp, '%Y-%m-%d %H:%M:%S')) + 0.001 * milliseconds
+                    if eventTime < startTime:
+                        continue
+                    for c in self.conditions:
+                        if c.match(eventType, message) != None and not line in alreadyPrintedLines:
+                            alreadyPrintedLines.add(line)
+                            self.printer.printMsg(">> %s" % line.strip())
+                            break
+                    elements = condition.match(eventType, message)
+                    if elements != None:
+                        return elements
+                log.seek(0, os.SEEK_CUR)
+                time.sleep(2)
+        finally:
+            self.printer.printMsg(">>>>> Finished monitoring %s log >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>" 
+                                  % self.logName)
+            
+class EventTypeCondition():
+    """ A condition which matches in case of specified event type. """
+    def __init__(self, eventType):
+        self.eventType = eventType
+        
+    def match(self, eventType, message):
+        return () if self.eventType == eventType else None
+        
+class StartsWithCondition():
+    """
+    A condition which matches if the message starts with a specified string.
+    """
+    def __init__(self, startsWithString):
+        self.startsWithString = startsWithString
+        
+    def match(self, eventType, message):
+        return () if message.startswith(self.startsWithString) else None
+    
+class RegexCondition():
+    """
+    A condition which matches if the message matches a specified regular expression.
+    """
+    def __init__(self, regex):
+        self.regex = regex
+        
+    def match(self, eventType, message):
+        match = re.search(self.regex, message)
+        return match.groups() if match else None
+    
+class ConditionSequence():
+    def __init__(self, conditions):
+        self.conditions = conditions
+        self.matches = []
+        
+    def match(self, eventType, message):
+        match_count = len(self.matches)
+        if match_count == len(self.conditions):
+            return self.matches
+        result = self.conditions[match_count].match(eventType, message)
+        if result is not None:
+            self.matches.append(result)
+            if len(self.matches) == len(self.conditions):
+                return self.matches
+        return None
\ No newline at end of file