You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by du...@apache.org on 2020/04/09 13:50:43 UTC

svn commit: r38870 [4/6] - in /release/sling: ./ src/ src/main/ src/main/java/ src/main/java/org/ src/main/java/org/apache/ src/main/java/org/apache/sling/ src/main/java/org/apache/sling/testing/ src/main/java/org/apache/sling/testing/clients/ src/main...

Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,898 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.sling.testing.clients.osgi;
+
+import org.apache.http.Header;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.sling.testing.clients.ClientException;
+import org.apache.sling.testing.clients.SlingClient;
+import org.apache.sling.testing.clients.SlingClientConfig;
+import org.apache.sling.testing.clients.SlingHttpResponse;
+import org.apache.sling.testing.clients.util.FormEntityBuilder;
+import org.apache.sling.testing.clients.util.HttpUtils;
+import org.apache.sling.testing.clients.util.JsonUtils;
+import org.apache.sling.testing.clients.util.poller.PathPoller;
+import org.apache.sling.testing.clients.util.poller.Polling;
+import org.codehaus.jackson.JsonNode;
+import org.osgi.framework.Constants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeoutException;
+import java.util.jar.JarInputStream;
+import java.util.jar.Manifest;
+
+import static org.apache.http.HttpStatus.SC_MOVED_TEMPORARILY;
+import static org.apache.http.HttpStatus.SC_OK;
+
+/**
+ * A client that wraps the Felix OSGi Web Console REST API calls.
+ * @see <a href=http://felix.apache.org/documentation/subprojects/apache-felix-web-console/web-console-restful-api.html>
+ *     Web Console RESTful API</a>
+ */
+public class OsgiConsoleClient extends SlingClient {
+
+    private static final Logger LOG = LoggerFactory.getLogger(OsgiConsoleClient.class);
+    /**
+     * All System Console REST API calls go to /system/console and below
+     */
+    private final String CONSOLE_ROOT_URL = "/system/console";
+
+    /**
+     * The URL for configuration requests
+     */
+    private final String URL_CONFIGURATION = CONSOLE_ROOT_URL + "/configMgr";
+
+    /**
+     * The URL for bundle requests
+     */
+    private final String URL_BUNDLES = CONSOLE_ROOT_URL + "/bundles";
+
+    /**
+     * The URL for components requests
+     */
+    private final String URL_COMPONENTS = CONSOLE_ROOT_URL + "/components";
+    
+    /**
+     * The URL for service requests
+     */
+    private final String URL_SERVICES = CONSOLE_ROOT_URL + "/services";
+
+
+    public static final String JSON_KEY_ID = "id";
+    public static final String JSON_KEY_VERSION = "version";
+    public static final String JSON_KEY_DATA = "data";
+    public static final String JSON_KEY_STATE = "state";
+
+    /**
+     * Default constructor. Simply calls {@link SlingClient#SlingClient(URI, String, String)}
+     *
+     * @param serverUrl the URL to the server under test
+     * @param userName the user name used for authentication
+     * @param password the password for this user
+     * @throws ClientException if the client cannot be instantiated
+     */
+    public OsgiConsoleClient(URI serverUrl, String userName, String password) throws ClientException {
+        super(serverUrl, userName, password);
+    }
+
+    /**
+     * Constructor used by adaptTo() and InternalBuilder classes. Should not be called directly in the code
+     *
+     * @param http http client to be used for requests
+     * @param config sling specific configs
+     * @throws ClientException if the client cannot be instantiated
+     */
+    public OsgiConsoleClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException {
+        super(http, config);
+    }
+
+    /**
+     * Returns the wrapper for the bundles info json
+     *
+     * @param expectedStatus list of accepted statuses of the response
+     * @return all the bundles info
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public BundlesInfo getBundlesInfo(int... expectedStatus) throws ClientException {
+        // request the bundles information
+        SlingHttpResponse resp = this.doGet(URL_BUNDLES + ".json", HttpUtils.getExpectedStatus(SC_OK, expectedStatus));
+        // return the wrapper
+        return new BundlesInfo(JsonUtils.getJsonNodeFromString(resp.getContent()));
+    }
+
+    /**
+     * Returns the wrapper for the bundle info json
+     *
+     * @param id the id of the bundle
+     * @param expectedStatus list of accepted statuses of the response
+     * @return the bundle info
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public BundleInfo getBundleInfo(String id, int... expectedStatus) throws ClientException {
+        SlingHttpResponse resp = this.doGet(URL_BUNDLES + "/" + id + ".json");
+        HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus));
+        return new BundleInfo(JsonUtils.getJsonNodeFromString(resp.getContent()));
+    }
+
+    /**
+     * Returns the wrapper for the components info json
+     *
+     * @param expectedStatus list of accepted statuses of the response
+     * @return the components info
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public ComponentsInfo getComponentsInfo(int... expectedStatus) throws ClientException {
+        SlingHttpResponse resp = this.doGet(URL_COMPONENTS + ".json");
+        HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus));
+        return new ComponentsInfo(JsonUtils.getJsonNodeFromString(resp.getContent()));
+    }
+
+    /**
+     * Returns the wrapper for the component info json
+     *
+     * @param id the id of the component
+     * @param expectedStatus list of accepted statuses of the response
+     * @return the component info
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public ComponentInfo getComponentInfo(String id, int expectedStatus) throws ClientException {
+        SlingHttpResponse resp = this.doGet(URL_COMPONENTS + "/" + id + ".json");
+        HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus));
+        return new ComponentInfo(JsonUtils.getJsonNodeFromString(resp.getContent()));
+    }
+
+    /**
+     * Returns the wrapper for the component info json
+     *
+     * @param id the id of the component
+     * @return the component info or {@code null} if the component with that name is not found
+     */
+    private ComponentInfo getComponentInfo(String name) throws ClientException {
+        SlingHttpResponse resp = this.doGet(URL_COMPONENTS + "/" + name + ".json");
+        if (HttpUtils.getHttpStatus(resp) == SC_OK) {
+            return new ComponentInfo(JsonUtils.getJsonNodeFromString(resp.getContent()));
+        }
+        return null;
+    }
+    
+    /**
+     * Returns the service info wrapper for all services implementing the given type.
+     *
+     * @param name the type of the service
+     * @return the service infos or {@code null} if no service for the given type is registered
+     */
+    private Collection<ServiceInfo> getServiceInfos(String type) throws ClientException {
+        SlingHttpResponse resp = this.doGet(URL_SERVICES + ".json");
+        if (HttpUtils.getHttpStatus(resp) == SC_OK) {
+            return new ServicesInfo(JsonUtils.getJsonNodeFromString(resp.getContent())).forType(type);
+        }
+        return null;
+    }
+
+    /**
+     * Wait until the component with the given name is registered. This means the component must be either in state "Registered" or "Active".
+     * The state registered is called "satisfied" in the Felix DS Web Console
+     * @param componentName the component's name
+     * @param timeout how long to wait for the component to become registered before throwing a {@code TimeoutException} in milliseconds
+     * @param delay time to wait between checks of the state in milliseconds
+     * @throws TimeoutException if the component did not become registered before timeout was reached
+     * @throws InterruptedException if interrupted
+     * @see "OSGi Comp. R6, §112.5 Component Life Cycle"
+     */
+    public void waitComponentRegistered(final String componentName, final long timeout, final long delay) throws TimeoutException, InterruptedException {
+        Polling p = new Polling() {
+            @Override
+            public Boolean call() throws Exception {
+                ComponentInfo info = getComponentInfo(componentName);
+                if (info != null) {
+                    return ((info.getStatus() == Component.Status.SATISFIED) || (info.getStatus() == Component.Status.ACTIVE));
+                } else {
+                    LOG.debug("Could not get component info for component name {}", componentName);
+                }
+                return false;
+            }
+
+            @Override
+            protected String message() {
+                return "Component " + componentName + " was not registered in %1$d ms";
+            }
+        };
+        p.poll(timeout, delay);
+    }
+    
+    /**
+     * Wait until the service with the given name is registered. This means the component must be either in state "Registered" or "Active".
+     * @param type the type of the service (usually the name of a Java interface)
+     * @param bundleSymbolicName the symbolic name of the bundle supposed to register that service. 
+     * May be {@code null} in which case this method just waits for any service with the requested type being registered (independent of the registering bundle).
+     * @param timeout how long to wait for the component to become registered before throwing a {@code TimeoutException} in milliseconds
+     * @param delay time to wait between checks of the state in milliseconds
+     * @throws TimeoutException if the component did not become registered before timeout was reached
+     * @throws InterruptedException if interrupted
+     */
+    public void waitServiceRegistered(final String type, final String bundleSymbolicName , final long timeout, final long delay) throws TimeoutException, InterruptedException {
+        Polling p = new Polling() {
+            @Override
+            public Boolean call() throws Exception {
+                Collection<ServiceInfo> infos = getServiceInfos(type);
+                if (infos != null) {
+                    if (bundleSymbolicName != null) {
+                        for (ServiceInfo info : infos) {
+                            if (bundleSymbolicName.equals(info.getBundleSymbolicName())) {
+                                return true;
+                            }
+                        }
+                        LOG.debug("Could not find service info for service type {} provided by bundle {}", type, bundleSymbolicName);
+                        return false;
+                    } else {
+                        return !infos.isEmpty();
+                    }
+                } else {
+                    LOG.debug("Could not find any service info for service type {}", type);
+                }
+                return false;
+            }
+
+            @Override
+            protected String message() {
+                return "Service with type " + type + " was not registered in %1$d ms";
+            }
+        };
+        p.poll(timeout, delay);
+    }
+
+    //
+    // OSGi configurations
+    //
+
+    /**
+     * Returns a map of all properties set for the config referenced by the PID, where the map keys
+     * are the property names.
+     *
+     * @param pid the pid of the configuration
+     * @param expectedStatus list of accepted statuses of the response
+     * @return the properties as a map
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public Map<String, Object> getConfiguration(String pid, int... expectedStatus) throws ClientException {
+        // make the request
+        SlingHttpResponse resp = this.doPost(URL_CONFIGURATION + "/" + pid, null);
+        // check the returned status
+        HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_OK, expectedStatus));
+        // get the JSON node
+        JsonNode rootNode = JsonUtils.getJsonNodeFromString(resp.getContent());
+        // go through the params
+        Map<String, Object> props = new HashMap<String, Object>();
+        if(rootNode.get("properties") == null)
+            return props;
+        JsonNode properties = rootNode.get("properties");
+        for(Iterator<String> it = properties.getFieldNames(); it.hasNext();) {
+            String propName = it.next();
+            JsonNode value = properties.get(propName).get("value");
+            if(value != null) {
+                props.put(propName, value.getValueAsText());
+                continue;
+            }
+            value = properties.get(propName).get("values");
+            if(value != null) {
+                Iterator<JsonNode> iter = value.getElements();
+                List<String> list = new ArrayList<String>();
+                while(iter.hasNext()) {
+                    list.add(iter.next().getValueAsText());
+                }
+                props.put(propName, list.toArray(new String[list.size()]));
+            }
+        }
+        return props;
+    }
+    /**
+     * Returns a map of all properties set for the config referenced by the PID, where the map keys
+     * are the property names. The method waits until the configuration has been set.
+     *
+     * @deprecated use {@link #waitGetConfiguration(long, String, int...)}
+     *
+     * @param waitCount The number of maximum wait intervals of 500ms.
+     *                  Between each wait interval, the method polls the backend to see if the configuration ahs been set.
+     * @param pid pid
+     * @param expectedStatus expected response status
+     * @return the config properties
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     * @throws InterruptedException to mark this operation as "waiting"
+     */
+    @Deprecated
+    public Map<String, Object> getConfigurationWithWait(long waitCount, String pid, int... expectedStatus)
+            throws ClientException, InterruptedException {
+        ConfigurationPoller poller = new ConfigurationPoller(pid, expectedStatus);
+        try {
+            poller.poll(500L * waitCount, 500);
+        } catch (TimeoutException e) {
+            throw new ClientException("Cannot retrieve configuration.", e);
+        }
+        return poller.getConfig();
+    }
+
+    /**
+     * Returns a map of all properties set for the config referenced by the PID, where the map keys
+     * are the property names. The method waits until the configuration has been set.
+     *
+     * @param timeout Maximum time to wait for the configuration to be available, in ms.
+     * @param pid service pid
+     * @param expectedStatus expected response status
+     * @return the config properties
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     * @throws InterruptedException to mark this operation as "waiting"
+     * @throws TimeoutException if the timeout was reached
+     */
+    public Map<String, Object> waitGetConfiguration(long timeout, String pid, int... expectedStatus)
+            throws ClientException, InterruptedException, TimeoutException {
+
+        ConfigurationPoller poller = new ConfigurationPoller(pid, expectedStatus);
+        poller.poll(timeout, 500);
+
+        return poller.getConfig();
+    }
+
+    /**
+     * Sets properties of a config referenced by its PID. the properties to be edited are passed as
+     * a map of property name,value pairs.
+     *
+     * @param PID Persistent identity string
+     * @param factoryPID Factory persistent identity string or {@code null}
+     * @param configProperties map of properties
+     * @param expectedStatus expected response status
+     * @return the location of the config
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public String editConfiguration(String PID, String factoryPID, Map<String, Object> configProperties, int... expectedStatus)
+            throws ClientException {
+        FormEntityBuilder builder = FormEntityBuilder.create();
+        builder.addParameter("apply", "true");
+        builder.addParameter("action", "ajaxConfigManager");
+        // send factory PID if set
+        if (factoryPID != null) {
+            builder.addParameter("factoryPid", factoryPID);
+        }
+        // add properties to edit
+        StringBuilder propertyList = new StringBuilder("");
+        for (String propName : configProperties.keySet()) {
+            Object o = configProperties.get(propName);
+            if (o instanceof String) {
+                builder.addParameter(propName, (String)o);
+            } else if (o instanceof String[]) {
+                for (String s : (String[])o) {
+                    builder.addParameter(propName, s);
+                }
+            }
+            propertyList.append(propName).append(",");
+        }
+        // cut off the last comma
+        builder.addParameter("propertylist", propertyList.substring(0, propertyList.length() - 1));
+        // make the request
+        SlingHttpResponse resp = this.doPost(URL_CONFIGURATION + "/" + PID, builder.build());
+        // check the returned status
+        HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(SC_MOVED_TEMPORARILY, expectedStatus));
+
+        Header[] locationHeader = resp.getHeaders("Location");
+        if (locationHeader!=null && locationHeader.length==1) {
+        	return locationHeader[0].getValue().substring(URL_CONFIGURATION.length()+1);
+        } else {
+        	return null;
+        }
+    }
+
+    /**
+     * Sets properties of a config referenced by its PID. the properties to be edited are passed as
+     * a map of property (name,value) pairs. The method waits until the configuration has been set.
+     *
+     * @deprecated use {@link #waitEditConfiguration(long, String, String, Map, int...)}
+     *
+     * @param waitCount The number of maximum wait intervals of 500ms.
+     *                  Between each wait interval, the method polls the backend to see if the configuration ahs been set.
+     * @param PID Persistent identity string
+     * @param factoryPID Factory persistent identity string or {@code null}
+     * @param configProperties map of properties
+     * @param expectedStatus expected response status
+     * @return the pid
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     * @throws InterruptedException to mark this operation as "waiting"
+     */
+    @Deprecated
+    public String editConfigurationWithWait(int waitCount, String PID, String factoryPID, Map<String, Object> configProperties,
+                                            int... expectedStatus) throws ClientException, InterruptedException {
+        String pid = editConfiguration(PID, factoryPID, configProperties, expectedStatus);
+        getConfigurationWithWait(waitCount, pid);
+        return pid;
+    }
+
+    /**
+     * Sets properties of a config referenced by its PID. the properties to be edited are passed as
+     * a map of property (name,value) pairs. The method waits until the configuration has been set.
+     *
+     * @param timeout Max time to wait for the configuration to be set, in ms
+     * @param PID Persistent identity string
+     * @param factoryPID Factory persistent identity string or {@code null}
+     * @param configProperties map of properties
+     * @param expectedStatus expected response status
+     * @return the pid
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     * @throws InterruptedException to mark this operation as "waiting"
+     * @throws TimeoutException if the timeout was reached
+     */
+    public String waitEditConfiguration(long timeout, String PID, String factoryPID, Map<String, Object> configProperties,
+                                        int... expectedStatus)
+            throws ClientException, InterruptedException, TimeoutException {
+        String pid = editConfiguration(PID, factoryPID, configProperties, expectedStatus);
+        waitGetConfiguration(timeout, pid);
+        return pid;
+    }
+
+    /**
+     * Delete the config referenced by the PID
+     *
+     * @param pid pid
+     * @param expectedStatus expected response status
+     * @return the sling response
+     * @throws ClientException if the response status does not match any of the expectedStatus
+     */
+    public SlingHttpResponse deleteConfiguration(String pid, int... expectedStatus) throws ClientException {
+        FormEntityBuilder builder = FormEntityBuilder.create();
+        builder.addParameter("apply", "1");
+        builder.addParameter("delete", "1");
+        // make the request
+        SlingHttpResponse resp = this.doPost(URL_CONFIGURATION + "/" + pid, builder.build());
+        // check the returned status
+        HttpUtils.verifyHttpStatus(resp, HttpUtils.getExpectedStatus(200, expectedStatus));
+        return resp;
+    }
+
+    //
+    // Bundles
+    //
+
+    /**
+     * Uninstall a bundle
+     * @param symbolicName bundle symbolic name
+     * @return the sling response
+     * @throws ClientException if something went wrong with the request
+     */
+    public SlingHttpResponse uninstallBundle(String symbolicName) throws ClientException {
+        final long bundleId = getBundleId(symbolicName);
+        LOG.info("Uninstalling bundle {} with bundleId {}", symbolicName, bundleId);
+        FormEntityBuilder builder = FormEntityBuilder.create();
+        builder.addParameter("action", "uninstall");
+        return this.doPost(getBundlePath(symbolicName), builder.build(), 200);
+    }
+
+    /**
+     * Install a bundle using the Felix webconsole HTTP interface
+     * @param f the bundle file
+     * @param startBundle whether to start the bundle or not
+     * @return the sling response
+     * @throws ClientException if the request failed
+     */
+    public SlingHttpResponse installBundle(File f, boolean startBundle) throws ClientException {
+        return installBundle(f, startBundle, 0);
+    }
+
+    /**
+     * Install a bundle using the Felix webconsole HTTP interface, with a specific start level
+     * @param f bundle file
+     * @param startBundle whether to start or just install the bundle
+     * @param startLevel start level
+     * @return the sling response
+     * @throws ClientException if the request failed
+     */
+    public SlingHttpResponse installBundle(File f, boolean startBundle, int startLevel) throws ClientException {
+        // Setup request for Felix Webconsole bundle install
+        MultipartEntityBuilder builder = MultipartEntityBuilder.create()
+                .addTextBody("action", "install")
+                .addBinaryBody("bundlefile", f);
+        if (startBundle) {
+            builder.addTextBody("bundlestart", "true");
+        }
+        if (startLevel > 0) {
+            builder.addTextBody("bundlestartlevel", String.valueOf(startLevel));
+            LOG.info("Installing bundle {} at start level {}", f.getName(), startLevel);
+        } else {
+            LOG.info("Installing bundle {} at default start level", f.getName());
+        }
+
+        return this.doPost(URL_BUNDLES, builder.build(), 302);
+
+    }
+
+    /**
+     * Check that specified bundle is installed and retries every {{waitTime}} milliseconds, until the
+     * bundle is installed or the number of retries was reached
+     * @deprecated does not respect polling practices; use {@link #waitBundleInstalled(String, long, long)} instead
+     * @param symbolicName the name of the bundle
+     * @param waitTime How many milliseconds to wait between retries
+     * @param retries the number of retries
+     * @return true if the bundle was installed until the retries stop, false otherwise
+     * @throws InterruptedException if interrupted
+     */
+    @Deprecated
+    public boolean checkBundleInstalled(String symbolicName, int waitTime, int retries) throws InterruptedException {
+        final String path = getBundlePath(symbolicName, ".json");
+        return new PathPoller(this, path, waitTime, retries).callAndWait();
+    }
+
+    /**
+     * Install a bundle using the Felix webconsole HTTP interface and wait for it to be installed
+     * @deprecated {@link #waitInstallBundle(File, boolean, int, long, long)}
+     * @param f the bundle file
+     * @param startBundle whether to start the bundle or not
+     * @param startLevel the start level of the bundle. negative values mean default start level
+     * @param waitTime how long to wait between retries of checking the bundle
+     * @param retries how many times to check for the bundle to be installed, until giving up
+     * @return true if the bundle was successfully installed, false otherwise
+     * @throws ClientException if the request failed
+     * @throws InterruptedException if interrupted
+     */
+    @Deprecated
+    public boolean installBundleWithRetry(File f, boolean startBundle, int startLevel, int waitTime, int retries)
+            throws ClientException, InterruptedException {
+        installBundle(f, startBundle, startLevel);
+        try {
+            return this.checkBundleInstalled(OsgiConsoleClient.getBundleSymbolicName(f), waitTime, retries);
+        } catch (IOException e) {
+            throw new ClientException("Cannot get bundle symbolic name", e);
+        }
+    }
+
+    /**
+     * Install a bundle using the Felix webconsole HTTP interface and wait for it to be installed.
+     * @param f the bundle file
+     * @param startBundle whether to start the bundle or not
+     * @param startLevel the start level of the bundle. negative values mean default start level
+     * @param timeout how long to wait for the bundle to be installed before throwing a {@code TimeoutException} in milliseconds
+     * @param delay time to wait between checks of the state in milliseconds
+     * @throws ClientException if the request failed
+     * @throws TimeoutException if the bundle did not install before timeout was reached
+     * @throws InterruptedException if interrupted
+     */
+    public void waitInstallBundle(File f, boolean startBundle, int startLevel, long timeout, long delay)
+            throws ClientException, InterruptedException, TimeoutException {
+
+        installBundle(f, startBundle, startLevel);
+        try {
+            waitBundleInstalled(getBundleSymbolicName(f), timeout, delay);
+        } catch (IOException e) {
+            throw new ClientException("Cannot get bundle symbolic name", e);
+        }
+    }
+
+    /**
+     * Wait until the bundle is installed.
+     * @param symbolicName symbolic name of bundle
+     * @param timeout how long to wait for the bundle to be installed before throwing a {@code TimeoutException} in milliseconds
+     * @param delay time to wait between checks of the state in milliseconds
+     * @throws TimeoutException if the bundle did not install before timeout was reached
+     * @throws InterruptedException if interrupted
+     * @see "OSGi Core R6, §4.4.2 Bundle State"
+     */
+    public void waitBundleInstalled(final String symbolicName, final long timeout, final long delay)
+            throws TimeoutException, InterruptedException {
+
+        final String path = getBundlePath(symbolicName);
+        Polling p = new Polling() {
+            @Override
+            public Boolean call() throws Exception {
+                return exists(path);
+            }
+
+            @Override
+            protected String message() {
+                return "Bundle " + symbolicName + " did not install in %1$d ms";
+            }
+        };
+
+        p.poll(timeout, delay);
+    }
+    
+    /**
+     * Wait until the bundle is started
+     * @param symbolicName symbolic name of bundle
+     * @param timeout how long to wait for the bundle to be installed before throwing a {@code TimeoutException} in milliseconds.
+     * @param delay time to wait between checks of the state in milliseconds.
+     * @throws TimeoutException if the bundle did not install before timeout was reached
+     * @throws InterruptedException if interrupted
+     * @see "OSGi Core R6, §4.4.2 Bundle State"
+     */
+    public void waitBundleStarted(final String symbolicName, final long timeout, final long delay)
+            throws TimeoutException, InterruptedException {
+
+        Polling p = new Polling() {
+            @Override
+            public Boolean call() throws Exception {
+                try {
+                    BundleInfo bundleInfo = getBundleInfo(symbolicName, 200);
+                    return (bundleInfo.getStatus() == Bundle.Status.ACTIVE);
+                } catch (ClientException e) {
+                    LOG.debug("Could not get bundle state for {}: {}", symbolicName, e.getLocalizedMessage(), e);
+                    return false;
+                }
+            }
+
+            @Override
+            protected String message() {
+                return "Bundle " + symbolicName + " did not start in %1$d ms";
+            }
+        };
+
+        p.poll(timeout, delay);
+    }
+
+    /**
+     * Get the id of the bundle
+     * @param symbolicName bundle symbolic name
+     * @return the id
+     * @throws ClientException if the id cannot be retrieved
+     */
+    public long getBundleId(String symbolicName) throws ClientException {
+        final JsonNode bundle = getBundleData(symbolicName);
+        final JsonNode idNode = bundle.get(JSON_KEY_ID);
+
+        if (idNode == null) {
+            throw new ClientException("Cannot get id from bundle json");
+        }
+
+        return idNode.getLongValue();
+    }
+
+    /**
+     * Get the version of the bundle
+     * @param symbolicName bundle symbolic name
+     * @return bundle version
+     * @throws ClientException if the version is not retrieved
+     */
+    public String getBundleVersion(String symbolicName) throws ClientException {
+        final JsonNode bundle = getBundleData(symbolicName);
+        final JsonNode versionNode = bundle.get(JSON_KEY_VERSION);
+
+        if (versionNode == null) {
+            throw new ClientException("Cannot get version from bundle json");
+        }
+
+        return versionNode.getTextValue();
+    }
+
+    /**
+     * Get the state of the bundle
+     * @param symbolicName bundle symbolic name
+     * @return the state of the bundle
+     * @throws ClientException if the state cannot be retrieved
+     */
+    public String getBundleState(String symbolicName) throws ClientException {
+        final JsonNode bundle = getBundleData(symbolicName);
+        final JsonNode stateNode = bundle.get(JSON_KEY_STATE);
+
+        if (stateNode == null) {
+            throw new ClientException("Cannot get state from bundle json");
+        }
+
+        return stateNode.getTextValue();
+    }
+
+    /**
+     * Starts a bundle
+     * @param symbolicName the name of the bundle
+     * @throws ClientException if the request failed
+     */
+    public void startBundle(String symbolicName) throws ClientException {
+        // To start the bundle we POST action=start to its URL
+        final String path = getBundlePath(symbolicName);
+        LOG.info("Starting bundle {} via {}", symbolicName, path);
+        this.doPost(path, FormEntityBuilder.create().addParameter("action", "start").build(), SC_OK);
+    }
+    
+    /**
+     * Stop a bundle
+     * @param symbolicName the name of the bundle
+     * @throws ClientException if the request failed
+     */
+    public void stopBundle(String symbolicName) throws ClientException {
+        // To stop the bundle we POST action=stop to its URL
+        final String path = getBundlePath(symbolicName);
+        LOG.info("Stopping bundle {} via {}", symbolicName, path);
+        this.doPost(path, FormEntityBuilder.create().addParameter("action", "stop").build(), SC_OK);
+    }
+
+
+    /**
+     * Starts a bundle and waits for it to be started
+     * @deprecated use {@link #waitStartBundle(String, long, long)}
+     * @param symbolicName the name of the bundle
+     * @param waitTime How many milliseconds to wait between retries
+     * @param retries the number of retries
+     * @throws ClientException if the request failed
+     * @throws InterruptedException if interrupted
+     */
+    @Deprecated
+    public void startBundlewithWait(String symbolicName, int waitTime, int retries)
+            throws ClientException, InterruptedException {
+        // start a bundle
+        startBundle(symbolicName);
+        // wait for it to be in the started state
+        checkBundleInstalled(symbolicName, waitTime, retries);
+    }
+
+    /**
+     * Starts a bundle and waits for it to be started
+     * @param symbolicName the name of the bundle
+     * @param timeout max time to wait for the bundle to start, in ms
+     * @param delay time to wait between status checks, in ms
+     * @throws ClientException if the request failed
+     * @throws InterruptedException if interrupted
+     * @throws TimeoutException if starting timed out
+     */
+    public void waitStartBundle(String symbolicName, long timeout, long delay)
+            throws ClientException, InterruptedException, TimeoutException {
+        startBundle(symbolicName);
+        // FIXME this should wait for the started state
+        waitBundleInstalled(symbolicName, timeout, delay);
+    }
+
+    /**
+     * Calls PackageAdmin.refreshPackages to force re-wiring of all the bundles.
+     * @throws ClientException if the request failed
+     */
+    public void refreshPackages() throws ClientException {
+        LOG.info("Refreshing packages.");
+        FormEntityBuilder builder = FormEntityBuilder.create();
+        builder.addParameter("action", "refreshPackages");
+        this.doPost(URL_BUNDLES, builder.build(), 200);
+    }
+
+
+    //
+    // private methods
+    //
+
+    private String getBundlePath(String symbolicName, String extension) {
+        return getBundlePath(symbolicName) + extension;
+    }
+
+    private String getBundlePath(String symbolicName) {
+        return URL_BUNDLES + "/" + symbolicName;
+    }
+
+    /**
+     * Returns a data structure like:
+     *
+     * {
+     *   "status" : "Bundle information: 173 bundles in total - all 173 bundles active.",
+     *   "s" : [173,171,2,0,0],
+     *   "data": [{
+     *     "id":0,
+     *     "name":"System Bundle",
+     *     "fragment":false,
+     *     "stateRaw":32,
+     *     "state":"Active",
+     *     "version":"3.0.7",
+     *     "symbolicName":"org.apache.felix.framework",
+     *     "category":""
+     *   }]
+     * }
+     */
+    private JsonNode getBundleData(String symbolicName) throws ClientException {
+        final String path = getBundlePath(symbolicName, ".json");
+        final String content = this.doGet(path, SC_OK).getContent();
+        final JsonNode root = JsonUtils.getJsonNodeFromString(content);
+
+        if (root.get(JSON_KEY_DATA) == null) {
+            throw new ClientException(path + " does not provide '" + JSON_KEY_DATA + "' element, JSON content=" + content);
+        }
+
+        Iterator<JsonNode> data = root.get(JSON_KEY_DATA).getElements();
+        if (!data.hasNext()) {
+            throw new ClientException(path + "." + JSON_KEY_DATA + " is empty, JSON content=" + content);
+        }
+
+        final JsonNode bundle = data.next();
+        if (bundle.get(JSON_KEY_STATE) == null) {
+            throw new ClientException(path + ".data[0].state missing, JSON content=" + content);
+        }
+
+        return bundle;
+    }
+
+    //
+    // static methods
+    //
+
+    /**
+     * Get the symbolic name from a bundle file by looking at the manifest
+     * @param bundleFile bundle file
+     * @return the name extracted from the manifest
+     * @throws IOException if reading the jar failed
+     */
+    public static String getBundleSymbolicName(File bundleFile) throws IOException {
+        String name = null;
+        final JarInputStream jis = new JarInputStream(new FileInputStream(bundleFile));
+        try {
+            final Manifest m = jis.getManifest();
+            if (m == null) {
+                throw new IOException("Manifest is null in " + bundleFile.getAbsolutePath());
+            }
+            name = m.getMainAttributes().getValue(Constants.BUNDLE_SYMBOLICNAME);
+        } finally {
+            jis.close();
+        }
+        return name;
+    }
+
+    /**
+     * Get the version form a bundle file by looking at the manifest
+     * @param bundleFile bundle file
+     * @return the version
+     * @throws IOException if reading the bundle jar failed
+     */
+    public static String getBundleVersionFromFile(File bundleFile) throws IOException {
+        String version = null;
+        final JarInputStream jis = new JarInputStream(new FileInputStream(bundleFile));
+        try {
+            final Manifest m = jis.getManifest();
+            if(m == null) {
+                throw new IOException("Manifest is null in " + bundleFile.getAbsolutePath());
+            }
+            version = m.getMainAttributes().getValue(Constants.BUNDLE_VERSION);
+        } finally {
+            jis.close();
+        }
+        return version;
+    }
+
+
+    class ConfigurationPoller extends Polling {
+
+        private final String pid;
+        private final int[] expectedStatus;
+        private Map<String, Object> config;
+
+        public ConfigurationPoller(String pid, int... expectedStatus) {
+            super();
+
+            this.pid = pid;
+            this.expectedStatus = expectedStatus;
+            this.config = null;
+        }
+
+        @Override
+        public Boolean call() throws Exception {
+            config = getConfiguration(pid, expectedStatus);
+            return config != null;
+        }
+
+        public Map<String, Object> getConfig() {
+            return config;
+        }
+    }
+}

Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiInstanceConfig.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiInstanceConfig.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/OsgiInstanceConfig.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.sling.testing.clients.osgi;
+
+import org.apache.sling.testing.clients.ClientException;
+import org.apache.sling.testing.clients.util.config.InstanceConfig;
+import org.apache.sling.testing.clients.util.config.InstanceConfigException;
+import org.apache.sling.testing.clients.SlingClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * <p>Allows saving and restoring the OSGiConfig to be used before and after altering OSGi configurations for tests</p>
+ * <p>See {@link InstanceConfig}</p>
+ */
+public class OsgiInstanceConfig implements InstanceConfig {
+    private static final Logger LOG = LoggerFactory.getLogger(OsgiInstanceConfig.class);
+
+    /**
+     * Time im ms to wait for retrieving the current osgi config for save() and restore()
+     */
+    private static final long WAIT_TIMEOUT = 20000;  // in ms
+
+    private final OsgiConsoleClient osgiClient;
+    private final String configPID;
+    private Map<String, Object> config;
+
+    @Deprecated
+    protected int waitCount = 20;
+
+    /**
+     *
+     * @param client The Granite Client to be used internally
+     * @param configPID The PID for the OSGi configuration
+     * @param <T> The type of the Granite Client
+     * @throws ClientException if the client cannot be initialized
+     * @throws InstanceConfigException if the config cannot be saved
+     * @throws InterruptedException if interrupted
+     */
+    public <T extends SlingClient> OsgiInstanceConfig(T client, String configPID)
+            throws ClientException, InstanceConfigException, InterruptedException {
+        this.osgiClient = client.adaptTo(OsgiConsoleClient.class);
+        this.configPID = configPID;
+
+        // Save the configuration
+        save();
+    }
+
+    /**
+     * Save the current OSGi configuration for the PID defined in the constructor
+     *
+     * @throws InstanceConfigException if the config cannot be saved
+     */
+    public InstanceConfig save() throws InstanceConfigException, InterruptedException {
+        try {
+            this.config = osgiClient.waitGetConfiguration(WAIT_TIMEOUT, this.configPID);
+            LOG.info("Saved OSGi config for {}. It is currently this: {}", this.configPID, this.config);
+        } catch (ClientException e) {
+            throw new InstanceConfigException("Error getting config", e);
+        } catch (TimeoutException e) {
+            throw new InstanceConfigException("Timeout of " + WAIT_TIMEOUT + " ms was reached while waiting for the configuration", e);
+        }
+        return this;
+    }
+
+    /**
+     * Restore the current OSGi configuration for the PID defined in the constructor
+     *
+     * @throws InstanceConfigException if the config cannot be restored
+     */
+    public InstanceConfig restore() throws InstanceConfigException, InterruptedException {
+        try {
+            osgiClient.waitEditConfiguration(WAIT_TIMEOUT, this.configPID, null, config);
+            LOG.info("restored OSGi config for {}. It is now this: {}", this.configPID, this.config);
+        } catch (ClientException e) {
+            throw new InstanceConfigException("Could not edit OSGi configuration", e);
+        } catch (TimeoutException e) {
+            throw new InstanceConfigException("Timeout of " + WAIT_TIMEOUT + " ms was reached while waiting for the configuration", e);
+        }
+        return this;
+    }
+}

Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.sling.testing.clients.osgi;
+
+import java.util.List;
+
+import org.apache.sling.testing.clients.ClientException;
+import org.codehaus.jackson.JsonNode;
+
+public class ServiceInfo {
+
+    private JsonNode service;
+
+    public ServiceInfo(JsonNode root) throws ClientException {
+        if(root.get("id") != null) {
+            service = root;
+        } else {
+            if(root.get("data") == null && root.get("data").size() < 1) {
+                throw new ClientException("No service info returned");
+            }
+            service = root.get("data").get(0);
+        }
+    }
+
+    /**
+     * @return the service identifier
+     */
+    public int getId() {
+        return service.get("id").getIntValue();
+    }
+
+    /**
+     * @return the service types name
+     */
+    public List<String> getTypes() {
+        // this is not a proper JSON array (https://issues.apache.org/jira/browse/FELIX-5762)
+        return ServicesInfo.splitPseudoJsonValueArray(service.get("types").getTextValue());
+    }
+
+    public String getPid() {
+        return service.get("pid").getTextValue();
+    }
+
+    /**
+     * @return the bundle id of the bundle exposing the service
+     */
+    public int getBundleId() {
+        return service.get("bundleId").getIntValue();
+    }
+
+    /**
+     * @return the bundle symbolic name of bundle implementing the service
+     */
+    public String getBundleSymbolicName() {
+        return service.get("bundleSymbolicName").getTextValue();
+    }
+
+}

Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package org.apache.sling.testing.clients.osgi;
+
+import org.apache.sling.testing.clients.ClientException;
+import org.codehaus.jackson.JsonNode;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * A simple Wrapper around the returned JSON when requesting the status of /system/console/services
+ */
+public class ServicesInfo {
+
+    private JsonNode root = null;
+
+    /**
+     * The only constructor.
+     * 
+     * @param root the root JSON node of the bundles info.
+     * @throws ClientException if the json does not contain the proper info
+     */
+    public ServicesInfo(JsonNode root) throws ClientException {
+        this.root = root;
+        // some simple sanity checks
+        if(root.get("status") == null)
+            throw new ClientException("No Status returned!");
+        if(root.get("serviceCount") == null)
+            throw new ClientException("No serviceCount returned!");
+    }
+
+    /**
+     * @return total number of bundles.
+     */
+    public int getTotalNumOfServices() {
+        return root.get("serviceCount").getIntValue();
+    }
+
+    /**
+     * Return service info for a service with given id
+     *
+     * @param id the id of the service
+     * @return the BundleInfo
+     * @throws ClientException if the info could not be retrieved
+     */
+    public ServiceInfo forId(String id) throws ClientException {
+        JsonNode serviceInfo = findBy("id", id);
+        return (serviceInfo != null) ? new ServiceInfo(serviceInfo) : null;
+    }
+
+    /**
+     * Return service infos for a bundle with name {@code name}
+     *
+     * @param type the type of the service
+     * @return a Collection of {@link ServiceInfo}s of all services with the given type. Might be empty, never {@code null}
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public Collection<ServiceInfo> forType(String type) throws ClientException {
+        List<ServiceInfo> results = new LinkedList<>();
+        List<JsonNode> serviceInfoNodes = findAllContainingValueInArray("types", type);
+        for (JsonNode serviceInfoNode : serviceInfoNodes) {
+            results.add(new ServiceInfo(serviceInfoNode));
+        }
+        return results;
+    }
+
+    private JsonNode findBy(String key, String value) {
+        List<JsonNode> result = findBy(key, value, true, false);
+        if (result.isEmpty()) {
+            return null;
+        } else {
+            return result.get(0);
+        }
+    }
+
+    private List<JsonNode> findAllContainingValueInArray(String key, String value) {
+        return findBy(key, value, false, true);
+    }
+    
+    private List<JsonNode> findBy(String key, String value, boolean onlyReturnFirstMatch, boolean arrayContainingMatch) {
+        Iterator<JsonNode> nodes = root.get("data").getElements();
+        List<JsonNode> results = new LinkedList<>();
+        while(nodes.hasNext()) {
+            JsonNode node = nodes.next();
+            if ((null != node.get(key)) && (node.get(key).isValueNode())) {
+                final String valueNode = node.get(key).getTextValue();
+                if (arrayContainingMatch) {
+                    if (splitPseudoJsonValueArray(valueNode).contains(value)) {
+                        results.add(node);
+                    }
+                } else {
+                    if (valueNode.equals(value)) {
+                        results.add(node);
+                    }
+                }
+            }
+        }
+        return results;
+    }
+
+    /**
+     * Array values are not returned as proper JSON array for Apache Felix.
+     * Therefore we need this dedicated split method, which extracts the individual values from this "pseudo" JSON array.
+     * Example value: 
+     * <pre>
+     * [java.lang.Runnable, org.apache.sling.event.impl.jobs.queues.QueueManager, org.osgi.service.event.EventHandler]
+     * </pre>
+     * @param value the value to split
+     * @return the list of the individual values in the given array.
+     * @see <a href="https://issues.apache.org/jira/browse/FELIX-5762">FELIX-5762</a>
+     */
+    static final List<String> splitPseudoJsonValueArray(String value) {
+        // is this an array?
+        if (value.startsWith("[") && value.length() >= 2) {
+            // strip of first and last character
+           String pureArrayValues = value.substring(1, value.length() - 1);
+           String[] resultArray = pureArrayValues.split(", |,");
+           return Arrays.asList(resultArray);
+        }
+        return Collections.singletonList(value);
+    }
+}
\ No newline at end of file

Added: release/sling/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/**
+ * OSGI testing tools.
+ */
+@Version("2.0.0")
+package org.apache.sling.testing.clients.osgi;
+
+import org.osgi.annotation.versioning.Version;

Added: release/sling/src/main/java/org/apache/sling/testing/clients/package-info.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/package-info.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/package-info.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+@Version("2.0.0")
+package org.apache.sling.testing.clients;
+
+import org.osgi.annotation.versioning.Version;

Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/QueryClient.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/query/QueryClient.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/query/QueryClient.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.sling.testing.clients.query;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.sling.testing.clients.ClientException;
+import org.apache.sling.testing.clients.SlingClient;
+import org.apache.sling.testing.clients.SlingClientConfig;
+import org.apache.sling.testing.clients.SlingHttpResponse;
+import org.apache.sling.testing.clients.osgi.OsgiConsoleClient;
+import org.apache.sling.testing.clients.query.servlet.QueryServlet;
+import org.apache.sling.testing.clients.util.JsonUtils;
+import org.apache.sling.testing.clients.util.URLParameterBuilder;
+import org.codehaus.jackson.JsonNode;
+import org.ops4j.pax.tinybundles.core.TinyBundles;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+import static org.apache.http.HttpStatus.SC_OK;
+
+/**
+ * <p>Sling client for performing oak queries.</p>
+ *
+ * <p>Uses a custom servlet {@link QueryServlet} to execute the query on the server
+ * and return the results as a json. If the servlet is not yet present, it automatically
+ * installs it and creates the corresponding nodes</p>
+ *
+ * <p>The servlet is exposed under {@value QueryServlet#SERVLET_PATH}.</p>
+ *
+ * <p>The servlet is not automatically uninstalled to avoid too much noise on the instance.
+ * The caller should take care of it, if needed, by calling {@link #uninstallServlet()}</p>
+ */
+public class QueryClient extends SlingClient {
+
+    /**
+     * Query types, as defined in {@code org.apache.jackrabbit.oak.query.QueryEngineImpl}
+     */
+    public enum QueryType {
+        SQL2("JCR-SQL2"),
+        SQL("sql"),
+        XPATH("xpath"),
+        JQOM("JCR-JQOM");
+
+        private final String name;
+
+        QueryType(String name) {
+            this.name = name;
+        }
+
+        @Override
+        public String toString() {
+            return name;
+        }
+    }
+
+    private static final Logger LOG = LoggerFactory.getLogger(QueryClient.class);
+
+    private static final String BUNDLE_BSN = "org.apache.sling.testing.clients.query";
+    private static final String BUNDLE_NAME = "Sling Testing Clients Query Servlet";
+    private static final String BUNDLE_VERSION = "1.0.0";
+
+    private static final long BUNDLE_START_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
+
+    /**
+     * Constructor used by adaptTo
+     *
+     * @param http underlying HttpClient
+     * @param config config state
+     * @throws ClientException if the client cannot be created
+     */
+    public QueryClient(CloseableHttpClient http, SlingClientConfig config) throws ClientException {
+        super(http, config);
+    }
+
+    /**
+     * Convenience constructor
+     *
+     * @param url host url
+     * @param user username
+     * @param password password
+     * @throws ClientException if the client cannot be constructed
+     */
+    public QueryClient(URI url, String user, String password) throws ClientException {
+        super(url, user, password);
+    }
+
+    /**
+     * Executes a query on the server and returns the results as a json
+     *
+     * @param query query to be executed
+     * @param type type of the query
+     * @return the results in json as exported by {@link QueryServlet}
+     * @throws ClientException if the request failed to execute
+     * @throws InterruptedException to mark that this method blocks
+     */
+    public JsonNode doQuery(final String query, final QueryType type) throws ClientException, InterruptedException {
+        return doQuery(query, type, true, false);
+    }
+
+    /**
+     * Executes a query on the server and returns only the number of rows in the result
+     *
+     * @param query query to be executed
+     * @param type type of the query
+     * @return total results returned by the query
+     * @throws ClientException if the request failed to execute
+     * @throws InterruptedException to mark that this method blocks
+     */
+    public long doCount(final String query, final QueryType type) throws ClientException, InterruptedException {
+        return doQuery(query, type, false, false).get("total").getLongValue();
+    }
+
+    /**
+     * Retrieves the plan of the query. Useful for determining which index is used
+     *
+     * @param query query to be executed
+     * @param type type of the query
+     * @return total results returned by the query
+     * @throws ClientException if the request failed to execute
+     * @throws InterruptedException to mark that this method blocks
+     */
+    public String getPlan(final String query, final QueryType type) throws ClientException, InterruptedException {
+        return doQuery(query, type, false, true).get("plan").toString();
+    }
+
+    protected JsonNode doQuery(final String query, final QueryType type, final boolean showResults, final boolean explain)
+            throws ClientException, InterruptedException {
+
+        List<NameValuePair> params = URLParameterBuilder.create()
+                .add("query", query)
+                .add("type", type.toString())
+                .add("showresults", Boolean.toString(showResults))
+                .add("explain", Boolean.toString(explain))
+                .getList();
+
+        try {
+            // try optimistically to execute the query
+            SlingHttpResponse response = this.doGet(QueryServlet.SERVLET_PATH, params, SC_OK);
+            return JsonUtils.getJsonNodeFromString(response.getContent());
+        } catch (ClientException e) {
+            if (e.getHttpStatusCode() == SC_NOT_FOUND) {
+                LOG.info("Could not find query servlet, will try to install it");
+                installServlet();
+                LOG.info("Retrying the query");
+                SlingHttpResponse response = this.doGet(QueryServlet.SERVLET_PATH, params, SC_OK);
+                return JsonUtils.getJsonNodeFromString(response.getContent());
+            } else {
+                throw e;
+            }
+        }
+    }
+
+    /**
+     * <p>Installs the servlet to be able to perform queries.</p>
+     *
+     * <p>By default, methods of this client automatically install the servlet if needed,
+     * so there is no need to explicitly call from outside</p>
+     *
+     * @return this
+     * @throws ClientException if the installation fails
+     * @throws InterruptedException to mark that this method blocks
+     */
+    public QueryClient installServlet() throws ClientException, InterruptedException {
+        InputStream bundleStream = TinyBundles.bundle()
+                .set("Bundle-SymbolicName", BUNDLE_BSN)
+                .set("Bundle-Version", BUNDLE_VERSION)
+                .set("Bundle-Name", BUNDLE_NAME)
+                .add(QueryServlet.class)
+                .build(TinyBundles.withBnd());
+
+        try {
+            File bundleFile = File.createTempFile(BUNDLE_BSN + "-" + BUNDLE_VERSION, ".jar");
+            Files.copy(bundleStream, bundleFile.toPath(), REPLACE_EXISTING);
+
+            adaptTo(OsgiConsoleClient.class).installBundle(bundleFile, true);
+            adaptTo(OsgiConsoleClient.class).waitBundleStarted(BUNDLE_BSN, BUNDLE_START_TIMEOUT, 100);
+
+            LOG.info("query servlet installed at {}", getUrl(QueryServlet.SERVLET_PATH));
+        } catch (IOException e) {
+            throw new ClientException("Failed to create the query servlet bundle", e);
+        } catch (TimeoutException e) {
+            throw new ClientException("The query servlet bundle did not successfully start", e);
+        }
+
+        return this;
+    }
+
+    /**
+     * Deletes all the resources created by {@link #installServlet()}
+     *
+     * @return this
+     * @throws ClientException if any of the resources fails to uninstall
+     */
+    public QueryClient uninstallServlet() throws ClientException {
+        adaptTo(OsgiConsoleClient.class).uninstallBundle(BUNDLE_BSN);
+        return this;
+    }
+}

Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/package-info.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/query/package-info.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/query/package-info.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/**
+ * Query tools leveraging javax.jcr.query
+ */
+@Version("0.1.0")
+package org.apache.sling.testing.clients.query;
+
+import org.osgi.annotation.versioning.Version;

Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/QueryServlet.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/QueryServlet.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/QueryServlet.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.sling.testing.clients.query.servlet;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.SlingHttpServletResponse;
+import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
+import org.osgi.service.component.annotations.Component;
+
+import javax.jcr.Session;
+import javax.jcr.query.*;
+import javax.servlet.Servlet;
+import javax.servlet.ServletException;
+import java.io.IOException;
+import java.util.Date;
+
+import static org.apache.sling.api.servlets.ServletResolverConstants.SLING_SERVLET_METHODS;
+import static org.apache.sling.api.servlets.ServletResolverConstants.SLING_SERVLET_PATHS;
+
+@Component(
+        name = QueryServlet.SERVLET_NAME,
+        service = {Servlet.class},
+        property = {
+                SLING_SERVLET_PATHS + "=" + QueryServlet.SERVLET_PATH,
+                SLING_SERVLET_METHODS + "=GET"
+        }
+)
+public class QueryServlet extends SlingSafeMethodsServlet {
+    private static final long serialVersionUID = 1L;
+
+    public static final String SERVLET_PATH = "/system/testing/query";
+    public static final String SERVLET_NAME = "Sling Testing Clients Query Servlet";
+
+    @Override
+    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
+            throws ServletException, IOException {
+
+        response.setContentType("application/json");
+        response.setCharacterEncoding("UTF-8");
+
+        try {
+            final QueryManager qm = request.getResourceResolver().adaptTo(Session.class)
+                    .getWorkspace().getQueryManager();
+
+            long before = 0;
+            long after = 0;
+            long total = 0;
+
+            String query = request.getParameter("query");
+            String type = request.getParameter("type");
+
+            // default for showResults is true, unless parameter is matching exactly "false"
+            boolean showResults = !("false".equalsIgnoreCase(request.getParameter("showresults")));
+            // default for explainQuery is false, unless parameter is present and is not matching "false"
+            String explainParam = request.getParameter("explain");
+            boolean explainQuery = (explainParam != null) && !("false".equalsIgnoreCase(explainParam));
+
+            boolean tidy = false;
+            for (String selector : request.getRequestPathInfo().getSelectors()) {
+                if ("tidy".equals(selector)) {
+                    tidy = true;
+                }
+            }
+
+            if ((query == null) || query.equals("") || (type == null) || type.equals("")) {
+                response.sendError(400, "Parameters query and type are required"); // invalid request
+                return;
+            }
+
+            // prepare
+            if (explainQuery) {
+                query = "explain " + query;
+            }
+
+            Query q = qm.createQuery(query, type);
+
+            // execute
+            before = new Date().getTime();
+            QueryResult result = q.execute();
+            after = new Date().getTime();
+
+            // collect results
+            String firstSelector = null;
+            if (result.getSelectorNames().length > 1) {
+                firstSelector = result.getSelectorNames()[0];
+                try {
+                    String[] columnNames = result.getColumnNames();
+                    if (columnNames.length > 0) {
+                        String firstColumnName = columnNames[0];
+                        int firstDot = firstColumnName.indexOf('.');
+                        if (firstDot > 0) {
+                            firstSelector = firstColumnName.substring(0, firstDot);
+                        }
+                    }
+                } catch (Exception ignored) {
+                }
+            }
+
+            ObjectMapper mapper = new ObjectMapper();
+            ObjectNode responseJson = mapper.createObjectNode();
+
+            if (explainQuery) {
+                responseJson.put("plan", result.getRows().nextRow().getValue("plan").getString());
+            } else if (showResults) {
+                ArrayNode results = mapper.createArrayNode();
+
+                RowIterator rows = result.getRows();
+                while (rows.hasNext()) {
+                    Row row = rows.nextRow();
+                    String rowPath = (firstSelector != null) ? row.getPath(firstSelector) : row.getPath();
+                    String rowType = (firstSelector != null)
+                            ? row.getNode(firstSelector).getPrimaryNodeType().getName()
+                            : row.getNode().getPrimaryNodeType().getName();
+
+                    ObjectNode rowJson = mapper.createObjectNode();
+                    rowJson.put("path", rowPath);
+                    rowJson.put("type", rowType);
+                    results.add(rowJson);
+
+                    total++;
+                }
+
+                responseJson.set("results", results);
+            } else {
+                // only count results
+                RowIterator rows = result.getRows();
+                while (rows.hasNext()) {
+                    rows.nextRow();
+                    total++;
+                }
+            }
+
+            responseJson.put("total", total);
+            responseJson.put("time", after - before);
+
+            if (tidy) {
+                response.getWriter().write(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(responseJson));
+            } else {
+                response.getWriter().write(responseJson.toString());
+            }
+
+        } catch (InvalidQueryException e) {
+            // Consider InvalidQueryException as an invalid request instead of sending 500 server error
+            response.sendError(400, e.getMessage());
+            e.printStackTrace(response.getWriter());
+        } catch (final Exception e) {
+            response.sendError(500, e.getMessage());
+            e.printStackTrace(response.getWriter());
+        }
+    }
+}

Added: release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/package-info.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/package-info.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/query/servlet/package-info.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/**
+ * Query tools leveraging javax.jcr.query
+ */
+@Version("1.2.2")
+package org.apache.sling.testing.clients.query.servlet;
+
+import org.osgi.annotation.versioning.Version;

Added: release/sling/src/main/java/org/apache/sling/testing/clients/util/FormEntityBuilder.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/util/FormEntityBuilder.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/util/FormEntityBuilder.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.sling.testing.clients.util;
+
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.message.BasicNameValuePair;
+
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Helper for creating Entity objects for POST requests.
+ */
+public class FormEntityBuilder {
+    public final static String DEFAULT_ENCODING = "UTF-8";
+
+    private final List<NameValuePair> params;
+    private String encoding;
+
+    public static FormEntityBuilder create() {
+        return new FormEntityBuilder();
+    }
+
+    FormEntityBuilder() {
+        params = new ArrayList<NameValuePair>();
+        encoding = DEFAULT_ENCODING;
+    }
+
+    public FormEntityBuilder addAllParameters(Map<String, String> parameters) {
+        if (parameters != null) {
+            for (String key : parameters.keySet()) {
+                addParameter(key, parameters.get(key));
+            }
+        }
+
+        return this;
+    }
+
+    public FormEntityBuilder addAllParameters(List<NameValuePair> parameters) {
+        if (parameters != null) {
+            params.addAll(parameters);
+        }
+
+        return this;
+    }
+
+    public FormEntityBuilder addParameter(String name, String value) {
+        params.add(new BasicNameValuePair(name, value));
+        return this;
+    }
+
+    public FormEntityBuilder setEncoding(String encoding) {
+        this.encoding = encoding;
+        return this;
+    }
+
+    public UrlEncodedFormEntity build() {
+        try {
+            return new UrlEncodedFormEntity(params, encoding);
+        } catch (UnsupportedEncodingException ue) {
+            throw new Error("Unexpected UnsupportedEncodingException", ue);
+        }
+    }
+}

Added: release/sling/src/main/java/org/apache/sling/testing/clients/util/HttpUtils.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/util/HttpUtils.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/util/HttpUtils.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.sling.testing.clients.util;
+
+import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.sling.testing.clients.ClientException;
+import org.apache.sling.testing.clients.SlingHttpResponse;
+
+
+import java.net.URI;
+
+
+public class HttpUtils {
+
+    /**
+     * Verify expected status and dump response in case expected status is not returned.
+     * Warning! It will try to consume the entity in case of error
+     *
+     * @param response       The Sling HTTP response
+     * @param expectedStatus List of acceptable HTTP Statuses
+     * @throws ClientException if status is not expected
+     */
+    public static void verifyHttpStatus(SlingHttpResponse response, int... expectedStatus) throws ClientException {
+        if (!checkStatus(response, expectedStatus)) {
+            throwError(response, buildDefaultErrorMessage(response), expectedStatus);
+        }
+    }
+
+    /**
+     * Verify expected status and show error message in case expected status is not returned.
+     *
+     * @param response       The SlingHttpResponse of an executed request.
+     * @param errorMessage   error message; if {@code null}, errorMessage is extracted from response
+     * @param expectedStatus List of acceptable HTTP Statuses
+     * @throws ClientException if status is not expected
+     */
+    public static void verifyHttpStatus(HttpResponse response, String errorMessage, int... expectedStatus)
+            throws ClientException {
+        if (!checkStatus(response, expectedStatus)) {
+            throwError(response, errorMessage, expectedStatus);
+        }
+    }
+
+    private static boolean checkStatus(HttpResponse response, int... expectedStatus)
+            throws ClientException {
+
+        // if no HttpResponse was given
+        if (response == null) {
+            throw new NullPointerException("The response is null!");
+        }
+
+        // if no expected statuses are given
+        if (expectedStatus == null || expectedStatus.length == 0) {
+            throw new IllegalArgumentException("At least one expected HTTP Status must be set!");
+        }
+
+        // get the returned HTTP Status
+        int givenStatus = getHttpStatus(response);
+
+        // check if it matches with an expected one
+        for (int expected : expectedStatus) {
+            if (givenStatus == expected) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private static boolean throwError(HttpResponse response, String errorMessage, int... expectedStatus)
+            throws ClientException {
+        // build error message
+        String errorMsg = "Expected HTTP Status: ";
+        for (int expected : expectedStatus) {
+            errorMsg += expected + " ";
+        }
+
+        // get the returned HTTP Status
+        int givenStatus = getHttpStatus(response);
+
+        errorMsg += ". Instead " + givenStatus + " was returned!\n";
+        if (errorMessage != null) {
+            errorMsg += errorMessage;
+        }
+
+        // throw the exception
+        throw new ClientException(errorMsg, givenStatus);
+    }
+
+
+    /**
+     * Build default error message
+     *
+     * @param resp The response of a sling request
+     * @return default error message
+     */
+    public static String buildDefaultErrorMessage(SlingHttpResponse resp) {
+
+        String content = resp.getContent();
+
+        // if no response content is available
+        if (content == null) return "";
+        String errorMsg = resp.getSlingMessage();
+
+        errorMsg = (errorMsg == null || errorMsg.length() == 0)
+                // any other returned content
+                ? " Response Content:\n" + content
+                // response message from sling response
+                : "Error Message: \n" + errorMsg;
+
+        return errorMsg;
+    }
+
+    /**
+     * Get HTTP Status of the response.
+     *
+     * @param response The RequestExecutor of an executed request.
+     * @return The HTTP Status of the response
+     * @throws ClientException never (kept for uniformity)
+     */
+    public static int getHttpStatus(HttpResponse response) throws ClientException {
+        return response.getStatusLine().getStatusCode();
+    }
+
+    /**
+     * Get the first 'Location' header and verify it's a valid URI.
+     *
+     * @param response HttpResponse the http response
+     * @return the location path
+     * @throws ClientException never (kept for uniformity)
+     */
+    public static String getLocationHeader(HttpResponse response) throws ClientException {
+        if (response == null) throw new ClientException("Response must not be null!");
+
+        String locationPath = null;
+        Header locationHeader = response.getFirstHeader("Location");
+        if (locationHeader != null) {
+            String location = locationHeader.getValue();
+            URI locationURI = URI.create(location);
+            locationPath = locationURI.getPath();
+        }
+
+        if (locationPath == null) {
+            throw new ClientException("not able to determine location path");
+        }
+        return locationPath;
+    }
+
+    /**
+     * Check if expected status is in range
+     *
+     * @param response the http response
+     * @param range    the http status range
+     * @return true if response is in range
+     */
+    public static boolean isInHttpStatusRange(HttpResponse response, int range) {
+        return range == response.getStatusLine().getStatusCode() / 100 * 100;
+    }
+
+    public static int[] getExpectedStatus(int defaultStatus, int... expectedStatus) {
+        if (expectedStatus == null || expectedStatus.length == 0) {
+            expectedStatus = new int[]{defaultStatus};
+        }
+        return expectedStatus;
+    }
+
+
+}
\ No newline at end of file

Added: release/sling/src/main/java/org/apache/sling/testing/clients/util/InputStreamBodyWithLength.java
==============================================================================
--- release/sling/src/main/java/org/apache/sling/testing/clients/util/InputStreamBodyWithLength.java (added)
+++ release/sling/src/main/java/org/apache/sling/testing/clients/util/InputStreamBodyWithLength.java Thu Apr  9 13:50:42 2020
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package org.apache.sling.testing.clients.util;
+
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.mime.content.InputStreamBody;
+import org.apache.sling.testing.clients.ClientException;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * If we want to upload a file that is a resource in a jar file, the http client expects a content length.
+ */
+public class InputStreamBodyWithLength extends InputStreamBody {
+    private long streamLength;
+
+    public InputStreamBodyWithLength(String resourcePath, String contentType, String fileName) throws ClientException {
+        super(ResourceUtil.getResourceAsStream(resourcePath), ContentType.create(contentType), fileName);
+        this.streamLength = getResourceStreamLength(resourcePath);
+    }
+
+    @Override
+    public long getContentLength() {
+        return streamLength;
+    }
+
+    /**
+     * Returns the length of a resource (which is needed for the InputStreamBody
+     * to work. Can't currently think of a better solution than going through
+     * the resource stream and count.
+     *
+     * @param resourcePath path to the file
+     * @return the size of the resource
+     */
+    private static long getResourceStreamLength(String resourcePath) throws ClientException {
+        int streamLength = 0;
+        InputStream stream = ResourceUtil.getResourceAsStream(resourcePath);
+        try {
+            for (int avail = stream.available(); avail > 0; avail = stream.available()) {
+                streamLength += avail;
+                stream.skip(avail);
+            }
+        } catch (IOException e) {
+            throw new ClientException("Could not read " + resourcePath + "!", e);
+        } finally {
+            try {
+                stream.close();
+            } catch (IOException e) {
+                throw new ClientException("Could not close Inputstream for " + resourcePath + "!", e);
+            }
+        }
+        return streamLength;
+    }
+}
\ No newline at end of file