Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/indexing/IndexingClient.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/indexing/IndexingClient.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/indexing/IndexingClient.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,454 @@
+/*******************************************************************************
+ * 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
+ * <p/>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p/>
+ * 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.indexing;
+
+import org.apache.commons.lang3.StringUtils;
+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.osgi.OsgiConsoleClient;
+import org.apache.sling.testing.clients.query.QueryClient;
+import org.apache.sling.testing.clients.util.poller.Polling;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static java.util.UUID.randomUUID;
+import static org.apache.http.HttpStatus.SC_OK;
+
+/**
+ * <p>Interface to the oak indexing mechanism</p>
+ *
+ * <p>Exposes {@link #waitForAsyncIndexing(long, long)} for waiting all the 
indexing lanes to finish
+ * indexing and to guarantee all the indices are up to date</p>
+ *
+ * <p>For using {@link #waitForAsyncIndexing(long, long)}, the user must have 
access rights to:</p>
+ *  <ul>
+ *      <li>read/write in {@code /tmp}</li>
+ *      <li>install bundles via {@link 
org.apache.sling.testing.clients.osgi.OsgiConsoleClient}
+ *      (if the query servlet was not previously installed)</li>
+ *  </ul>
+ *  <p>In short, it requires administrative rights.</p>
+ */
+public class IndexingClient extends SlingClient {
+    private static final Logger LOG = 
LoggerFactory.getLogger(IndexingClient.class);
+
+    /** Configuration name in {@link SlingClientConfig} to be used to 
initialize pre-defined index lanes
+     *  Configured value, if any, is supposed to be an array of lane names
+     */
+    private static final String INDEX_LANES_CSV_CONFIG_NAME = "indexLanesCsv";
+
+    /** Root of all the data created by this tool. Its presence marks that it 
was already installed */
+    private static final String WAIT_FOR_ASYNC_INDEXING_ROOT = 
"/tmp/testing/waitForAsyncIndexing";
+
+    /** Where new index definitions are added */
+    private static final String INDEX_PATH = WAIT_FOR_ASYNC_INDEXING_ROOT + 
"/oak:index";
+
+    /** Where the content to be indexed is created */
+    private static final String CONTENT_PATH = WAIT_FOR_ASYNC_INDEXING_ROOT + 
"/content";
+
+    /** Prefix to be added to all the index names */
+    private static final String INDEX_PREFIX = "testIndexingLane-";
+
+    /** Prefix to be added to all the properties */
+    private static final String PROPERTY_PREFIX = "testProp-";
+
+    /** Prefix to be added to all the property values */
+    private static final String VALUE_PREFIX = "testasyncval-";
+
+    /** Prefix to be added to all the tags */
+    private static final String TAG_PREFIX = "testTag";
+
+    /** Placeholder for index name */
+    private static final String INDEX_NAME_PLACEHOLDER = "<<INDEXNAME>>";
+
+    /** Placeholder for random, unique parts in content and queries */
+    private static final String PROPERTY_PLACEHOLDER = "<<PROPNAME>>";
+
+    /** Placeholder for random, unique parts in content and queries */
+    private static final String VALUE_PLACEHOLDER = "<<RANDVAL>>";
+
+    /** Placeholder for identifying the lane to which the index and the 
content belongs to */
+    private static final String LANE_PLACEHOLDER = "<<LANE>>";
+
+    /** Placeholder for identifying the tag to which the index and the queries 
belongs to */
+    private static final String TAG_PLACEHOLDER = "<<TAG>>";
+
+    /** Template for index definitions to be installed */
+    private static final String INDEX_DEFINITION = "{" +
+            "  '" + INDEX_NAME_PLACEHOLDER + "': {\n" +
+            "    'jcr:primaryType': 'oak:QueryIndexDefinition',\n" +
+            "    'type': 'lucene',\n" +
+            "    'async': '" + LANE_PLACEHOLDER + "',\n" +
+            "    'tags': '" + TAG_PLACEHOLDER + "',\n" +
+            "    'indexRules': {\n" +
+            "      'jcr:primaryType': 'nt:unstructured',\n" +
+            "      'nt:base': {\n" +
+            "        'jcr:primaryType': 'nt:unstructured',\n" +
+            "        'properties': {\n" +
+            "          'jcr:primaryType': 'nt:unstructured',\n" +
+            "          '" + PROPERTY_PLACEHOLDER + "': {\n" +
+            "            'jcr:primaryType': 'nt:unstructured',\n" +
+            "            'name': '" + PROPERTY_PLACEHOLDER + "',\n" +
+            "            'analyzed': true\n" +
+            "            }\n" +
+            "          }\n" +
+            "        }\n" +
+            "      }\n" +
+            "    }" +
+            "}";
+
+    /** Template for the content to be created and searched */
+    private static final String CONTENT_DEFINITION = "{" +
+            "'testContent-" + LANE_PLACEHOLDER + "-" + VALUE_PLACEHOLDER + "': 
{" +
+            "  'jcr:primaryType': 'nt:unstructured', " +
+            "  '" + PROPERTY_PLACEHOLDER +"': '" + VALUE_PLACEHOLDER + "'" +
+            "}}";
+
+
+    /** Templates for queries to be executed against each index, in order of 
priority */
+    private static final List<String> QUERIES = Arrays.asList(
+            // for Oak versions that support option(index tag testTag)
+            "/jcr:root" + WAIT_FOR_ASYNC_INDEXING_ROOT + "//*" +
+                    "[jcr:contains(@" + PROPERTY_PLACEHOLDER + ", '" + 
VALUE_PLACEHOLDER +"')] " +
+                    "option(traversal ok, index tag " + TAG_PLACEHOLDER + ")",
+            // for older Oak versions
+            "/jcr:root" + WAIT_FOR_ASYNC_INDEXING_ROOT + "//*" +
+                    "[jcr:contains(@" + PROPERTY_PLACEHOLDER + ", '" + 
VALUE_PLACEHOLDER +"')] " +
+                    "option(traversal ok)"
+    );
+
+    /** Global counter for how much time was spent in total waiting for async 
indexing */
+    private static final AtomicLong totalWaited = new AtomicLong();
+    public static final String ASYNC_INDEXER_CONFIG = 
"org.apache.jackrabbit.oak.plugins.index.AsyncIndexerService";
+
+    /**
+     * Constructor used by Builders and adaptTo(). <b>Should never be called 
directly from the code.</b>
+     *
+     * @param http the underlying HttpClient to be used
+     * @param config sling specific configs
+     * @throws ClientException if the client could not be created
+     */
+    public IndexingClient(CloseableHttpClient http, SlingClientConfig config) 
throws ClientException {
+        super(http, config);
+    }
+
+    /**
+     * <p>Handy constructor easy to use in simple tests. Creates a client that 
uses basic authentication.</p>
+     *
+     * <p>For constructing clients with complex configurations, use a {@link 
InternalBuilder}</p>
+     *
+     * <p>For constructing clients with the same configuration, but a 
different class, use {@link #adaptTo(Class)}</p>
+     *
+     * @param url url of the server (including context path)
+     * @param user username for basic authentication
+     * @param password password for basic authentication
+     * @throws ClientException never, kept for uniformity with the other 
constructors
+     */
+    public IndexingClient(URI url, String user, String password) throws 
ClientException {
+        super(url, user, password);
+    }
+
+    /**
+     * Set provided {@code laneNames} to config map. This allows for 
subsequent initializations
+     * using {@code adaptTo} that shard the same config map to not require 
further configuration
+     * of lane names
+     * @param laneNames lane names to work on
+     */
+    public void setLaneNames(String ... laneNames) {
+        getValues().put(INDEX_LANES_CSV_CONFIG_NAME, 
StringUtils.join(laneNames, ','));
+    }
+
+    /**
+     * Return the list of indexing lanes configured by {@link #setLaneNames}, 
if any.
+     * Else, retrieves configured lanes on the instance
+     *
+     * @return list of lane names
+     * @throws ClientException if the request fails
+     */
+    public List<String> getLaneNames() throws ClientException {
+        List<String> configuredLanes = getConfiguredLaneNames();
+        if (!configuredLanes.isEmpty()) {
+            return configuredLanes;
+        }
+
+        Object configs = 
adaptTo(OsgiConsoleClient.class).getConfiguration(ASYNC_INDEXER_CONFIG).get("asyncConfigs");
+        if (configs instanceof String[]) {
+            return Stream.of((String[]) configs).map(e -> 
e.split(":")[0]).collect(Collectors.toList());
+        } else {
+            throw new ClientException("Cannot retrieve config from 
AsyncIndexerService, asyncConfigs is not a String[]");
+        }
+    }
+
+    private List<String> getConfiguredLaneNames() {
+        String configLanesCsv = getValue(INDEX_LANES_CSV_CONFIG_NAME);
+        if (configLanesCsv == null) {
+            return Collections.emptyList();
+        }
+        return 
Collections.unmodifiableList(Stream.of(configLanesCsv.split(","))
+                .map(e -> e.trim()).collect(Collectors.toList()));
+    }
+
+    /**
+     * <p>Blocks until all the async indices are up to date, to guarantee that 
the susequent queries return
+     * all the results.</p>
+     *
+     * <p>Works by creating a custom index for each lane, adding specific 
content to
+     * be indexed by these indices and then repeatedly searching this content 
until everything is found (indexed).
+     * All the content is created under {@value 
#WAIT_FOR_ASYNC_INDEXING_ROOT}</p>
+     *
+     * <p>Indices are automatically created, but only if not already present.
+     * This method does not delete the indices at the end to avoid generating 
too much noise on the instance.
+     * To completely clean any traces, the user must call {@link 
#uninstall()}</p>
+     *
+     * <p>Requires administrative rights to install bundles and to create 
nodes under
+     * {@value #WAIT_FOR_ASYNC_INDEXING_ROOT}</p>
+     *
+     * @param timeout max time to wait, in milliseconds, before throwing 
{@code TimeoutException}
+     * @param delay time to sleep between retries
+     * @throws TimeoutException if the {@code timeout} was reached before all 
the indices were updated
+     * @throws InterruptedException to mark this method as waiting
+     * @throws ClientException if an error occurs during http 
requests/responses
+     */
+    public void waitForAsyncIndexing(final long timeout, final long delay)
+            throws TimeoutException, InterruptedException, ClientException {
+
+        install();  // will install only if needed
+
+        final String uniqueValue = randomUUID().toString();  // to be added in 
all the content nodes
+        final List<String> lanes = getLaneNames();  // dynamically detect 
which lanes to wait for
+
+        Polling p = new Polling(() -> searchContent(lanes, uniqueValue));
+
+        try {
+            createContent(lanes, uniqueValue);
+            p.poll(timeout, delay);
+        } finally {
+            long total = totalWaited.addAndGet(p.getWaited()); // count waited 
in all the cases (timeout)
+            LOG.info("Waited for async index {} ms (overall: {} ms)", 
p.getWaited(), total);
+            try {
+                deleteContent(uniqueValue);
+            } catch (ClientException e) {
+                LOG.warn("Failed to delete temporary content", e);
+            }
+        }
+    }
+
+    /**
+     * Same as {@link #waitForAsyncIndexing(long timeout, long delay)},
+     * but with default values for {@code timeout=1min} and {@code 
delay=500ms}.
+     *
+     * @see #waitForAsyncIndexing(long, long)
+     *
+     * @throws TimeoutException if the {@code timeout} was reached before all 
the indices were updated
+     * @throws InterruptedException to mark this method as waiting
+     * @throws ClientException if an error occurs during http 
requests/responses
+     */
+    public void waitForAsyncIndexing() throws InterruptedException, 
ClientException, TimeoutException {
+        waitForAsyncIndexing(TimeUnit.MINUTES.toMillis(1), 500);
+    }
+
+    /**
+     * <p>Creates the necessary custom indices in the repository, if not 
already present.</p>
+     *
+     * <p>It is automatically called in each wait, there's no need to
+     * explicitly invoke it from the test.</p>
+     *
+     * @throws ClientException if the installation fails
+     */
+    public void install() throws ClientException {
+        if (exists(WAIT_FOR_ASYNC_INDEXING_ROOT)) {
+            LOG.debug("Skipping install since {} already exists", 
WAIT_FOR_ASYNC_INDEXING_ROOT);
+            return;
+        }
+
+        createNodeRecursive(WAIT_FOR_ASYNC_INDEXING_ROOT, "sling:Folder");
+        createNode(INDEX_PATH, "nt:unstructured");
+        createNode(CONTENT_PATH, "sling:Folder");
+
+        final List<String> lanes = getLaneNames();
+        for (String lane : lanes) {
+            String indexName = getIndexName(lane);
+            String indexDefinition = replacePlaceholders(INDEX_DEFINITION, 
lane, null);
+            LOG.info("Creating index {} in {}", indexName, INDEX_PATH);
+            LOG.debug("Index definition: {}", indexDefinition);
+            importContent(INDEX_PATH, "json", indexDefinition);
+            // Trigger reindex to make sure the complete index definition is 
used
+            setPropertyString(INDEX_PATH + "/" + indexName, "reindex", "true");
+        }
+    }
+
+    /**
+     * <p>Cleans all the data generated by {@link #install()} and {@link 
#waitForAsyncIndexing(long, long)}.</p>
+     *
+     * <p>User must manually call this if needed, as opposed to {@link 
#install()}, which is called
+     * automatically.</p>
+     *
+     * @deprecated Use #uninstallWithRetry
+     * @throws ClientException if the cleanup failed
+     */
+    public void uninstall() throws ClientException {
+        this.deletePath(WAIT_FOR_ASYNC_INDEXING_ROOT, SC_OK);
+    }
+
+    /**
+     * <p>Retries cleaning all the data generated by {@link #install()} and 
{@link #waitForAsyncIndexing(long, long)}.</p>
+     *
+     * <p>User must manually call this if needed, as opposed to {@link 
#install()}, which is called
+     * automatically.</p>
+     *
+     * @throws TimeoutException if retry operation times out and the path 
still exists
+     * @throws InterruptedException if the retry operation was interrupted by 
the user
+     */
+    public void uninstallWithRetry() throws TimeoutException, 
InterruptedException {
+        new Polling(() -> {
+            this.deletePath(WAIT_FOR_ASYNC_INDEXING_ROOT, SC_OK);
+            return this.exists(WAIT_FOR_ASYNC_INDEXING_ROOT);
+        }).poll(5000, 500);
+    }
+
+    /**
+     * Creates all the content structures to be indexed, one for each lane,
+     * with the given {@code uniqueValue}, to make them easily identifiable
+     *
+     * @param lanes list of lanes for which to create the content
+     * @param uniqueValue the unique value to be added
+     * @throws ClientException if the content creation fails
+     */
+    private void createContent(final List<String> lanes, final String 
uniqueValue) throws ClientException {
+        // All the content is grouped under the same node
+        String contentHolder = CONTENT_PATH + "/" + uniqueValue;
+        LOG.debug("creating content in {}", contentHolder);
+        createNode(contentHolder, "sling:Folder");
+
+        for (String lane : lanes) {
+            String contentNode = replacePlaceholders(CONTENT_DEFINITION, lane, 
uniqueValue);
+            LOG.debug("creating: {}", contentNode);
+            importContent(contentHolder, "json", contentNode);
+        }
+    }
+
+    /**
+     * Deletes the temporary nodes created in {@link #createContent(List, 
String)}
+     *
+     * @throws ClientException if the content cannot be deleted
+     */
+    private void deleteContent(String uniqueValue) throws ClientException {
+        if (uniqueValue != null) {
+            String contentHolder = CONTENT_PATH + "/" + uniqueValue;
+            LOG.debug("deleting {}", contentHolder);
+            deletePath(contentHolder, SC_OK);
+        }
+    }
+
+    /**
+     * Performs queries for each of the created content and checks that all 
return results
+     *
+     * @param lanes list of lanes for which to run queries
+     * @param uniqueValue the unique value to be used in queries
+     * @return true if all the queries returned at least one result (all 
indices are up to date)
+     * @throws ClientException if the http request failed
+     * @throws InterruptedException to mark this method as waiting
+     */
+    private boolean searchContent(final List<String> lanes, final String 
uniqueValue)
+            throws ClientException, InterruptedException {
+        for (String lane : lanes) {
+            if (!searchContentForIndex(lane, uniqueValue)) {
+                return false;
+            }
+        }
+        // Queries returned at least one result for each index
+        return true;
+    }
+
+    /**
+     * Tries all the known queries for a specific index lane,
+     * until one of them returns at least one result.
+     *
+     * @param lane the indexing lane to query
+     * @param uniqueValue the unique value to be used in queries
+     * @return true if at least one query returned results
+     * @throws ClientException if the http request fails
+     * @throws InterruptedException to mark this method as waiting
+     */
+    private boolean searchContentForIndex(final String lane, final String 
uniqueValue)
+            throws ClientException, InterruptedException {
+        QueryClient queryClient = adaptTo(QueryClient.class);
+
+        for (String query : QUERIES) {
+            // prepare the query with the final values
+            String indexName = getIndexName(lane);
+            String effectiveQuery = replacePlaceholders(query, lane, 
uniqueValue);
+
+            try {
+                // Check query plan to make sure we use the good index
+                String plan = queryClient.getPlan(effectiveQuery, 
QueryClient.QueryType.XPATH);
+                if (plan.contains(indexName)) {
+                    // The proper index is used, we can check the results
+                    long results = queryClient.doCount(effectiveQuery, 
QueryClient.QueryType.XPATH);
+                    if (results > 0) {
+                        LOG.debug("Found {} results using query {}", results, 
effectiveQuery);
+                        return true;
+                    }
+                } else {
+                    LOG.debug("Did not find index {} in plan: {}", indexName, 
plan);
+                    LOG.debug("Will try the next query, if available");
+                }
+            } catch (ClientException e) {
+                if (e.getHttpStatusCode() == 400) {
+                    LOG.debug("Unsupported query: {}", effectiveQuery);
+                    LOG.debug("Will try the next query, if available");
+                } else {
+                    // We don't continue if there's another problem
+                    throw e;
+                }
+            }
+        }
+        // No query returned results
+        return false;
+    }
+
+    private String replacePlaceholders(String original, String lane, String 
value) {
+        // Tags must be alphanumeric
+        String tag = StringUtils.capitalize(lane.replaceAll("[^A-Za-z0-9]", 
""));
+
+        String result = original;
+        result = StringUtils.replace(result, LANE_PLACEHOLDER, lane);
+        result = StringUtils.replace(result, INDEX_NAME_PLACEHOLDER, 
INDEX_PREFIX + lane);
+        result = StringUtils.replace(result, PROPERTY_PLACEHOLDER, 
PROPERTY_PREFIX + lane);
+        result = StringUtils.replace(result, VALUE_PLACEHOLDER, VALUE_PREFIX + 
value);
+        result = StringUtils.replace(result, TAG_PLACEHOLDER, TAG_PREFIX + 
tag);
+
+        return result;
+    }
+
+    private String getIndexName(final String lane) {
+        return INDEX_PREFIX + lane;
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/indexing/package-info.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/indexing/package-info.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/indexing/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("0.2.0")
+package org.apache.sling.testing.clients.indexing;
+
+import org.osgi.annotation.versioning.Version;

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/InstanceConfiguration.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/InstanceConfiguration.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/InstanceConfiguration.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,60 @@
+/*
+ * 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.instance;
+
+import java.net.URI;
+
+/**
+ * Configuration of a single instance instance.
+ */
+public class InstanceConfiguration {
+
+    public static final String DEFAULT_ADMIN_USER = "admin";
+    public static final String DEFAULT_ADMIN_PASSWORD = "admin";
+
+    private URI url;
+    private final String runmode;
+    private String adminUser;
+    private String adminPassword;
+
+    public InstanceConfiguration(final URI url, final String runmode, String 
adminUser, String adminPassword) {
+        this.url = url;
+        this.runmode = runmode;
+        this.adminUser = adminUser;
+        this.adminPassword = adminPassword;
+    }
+
+    public InstanceConfiguration(URI url, String runmode) {
+        this(url, runmode, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD);
+    }
+
+    public URI getUrl() {
+        return this.url;
+    }
+
+    public String getRunmode() {
+        return runmode;
+    }
+
+    public String getAdminUser() {
+        return adminUser;
+    }
+
+    public String getAdminPassword() {
+        return adminPassword;
+    }
+}
\ No newline at end of file

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/InstanceSetup.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/InstanceSetup.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/InstanceSetup.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,102 @@
+/*
+ * 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.instance;
+
+import org.apache.sling.testing.clients.SystemPropertiesConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility class for getting the current instance setup
+ */
+public final class InstanceSetup {
+    private static final Logger LOG = 
LoggerFactory.getLogger(InstanceSetup.class);
+    private static InstanceSetup SINGLETON;
+
+    // TODO: JAVADOC
+    public static final String INSTANCE_CONFIG_INSTANCES = 
SystemPropertiesConfig.CONFIG_PROP_PREFIX + "instances";
+    public static final String INSTANCE_CONFIG_URL = 
SystemPropertiesConfig.CONFIG_PROP_PREFIX + "instance.url.";
+    public static final String INSTANCE_CONFIG_RUNMODE = 
SystemPropertiesConfig.CONFIG_PROP_PREFIX + "instance.runmode.";
+    public static final String INSTANCE_CONFIG_ADMINUSER = 
SystemPropertiesConfig.CONFIG_PROP_PREFIX + "instance.adminUser.";
+    public static final String INSTANCE_CONFIG_ADMINPASSWORD = 
SystemPropertiesConfig.CONFIG_PROP_PREFIX + "instance.adminPassword.";
+
+    /**
+     * @return  the current setup object.
+     */
+    public static InstanceSetup get() {
+        if ( SINGLETON == null ) {
+            SINGLETON = new InstanceSetup();
+        }
+        return SINGLETON;
+    }
+
+    private final List<InstanceConfiguration> configs = new 
ArrayList<InstanceConfiguration>();
+
+    private InstanceSetup() {
+        final int number = 
Integer.valueOf(System.getProperty(INSTANCE_CONFIG_INSTANCES, "0"));
+        for (int i=1; i<=number; i++ ) {
+            URI url;
+            try {
+                url = new URI(System.getProperty(INSTANCE_CONFIG_URL + 
String.valueOf(i)));
+            } catch (URISyntaxException e) {
+                LOG.error("Could not read URL for instance");
+                continue;
+            }
+            final String runmode = System.getProperty(INSTANCE_CONFIG_RUNMODE 
+ String.valueOf(i));
+            final String adminUser = 
System.getProperty(INSTANCE_CONFIG_ADMINUSER + String.valueOf(i));
+            final String adminPassword = 
System.getProperty(INSTANCE_CONFIG_ADMINPASSWORD + String.valueOf(i));
+
+            final InstanceConfiguration qc;
+            // Only pass in the admin user name and password if they're both 
set
+            if ((null == adminUser) || (null == adminPassword)) {
+                qc = new InstanceConfiguration(url, runmode);
+            } else {
+                qc = new InstanceConfiguration(url, runmode, adminUser, 
adminPassword);
+            }
+
+            this.configs.add(qc);
+        }
+    }
+
+    /**
+     * @return all instance configurations.
+     */
+    public List<InstanceConfiguration> getConfigurations() {
+        return this.configs;
+    }
+
+    /**
+     * Get the list of all InstanceConfiguration with a specific {@code 
runmode}
+     *
+     * @param runmode the desired runmode
+     * @return all instance configurations filtered by runmode.
+     */
+    public List<InstanceConfiguration> getConfigurations(final String runmode) 
{
+        final List<InstanceConfiguration> result = new 
ArrayList<InstanceConfiguration>();
+        for(final InstanceConfiguration qc : this.configs) {
+            if ( runmode == null || runmode.equals(qc.getRunmode()) ) {
+                result.add(qc);
+            }
+        }
+        return result;
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/package-info.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/package-info.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/instance/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("1.1.0")
+package org.apache.sling.testing.clients.instance;
+
+import org.osgi.annotation.versioning.Version;

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/DelayRequestInterceptor.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/DelayRequestInterceptor.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/DelayRequestInterceptor.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,47 @@
+/*
+ * 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.interceptors;
+
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.protocol.HttpContext;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+
+public class DelayRequestInterceptor implements HttpRequestInterceptor {
+
+    private final long milliseconds;
+
+    public DelayRequestInterceptor(long milliseconds) {
+        this.milliseconds = milliseconds;
+    }
+
+    public void process(HttpRequest request, HttpContext context) throws 
HttpException, IOException {
+        if (milliseconds <= 0) {
+            return;
+        }
+
+        try {
+            Thread.sleep(milliseconds);
+        } catch (InterruptedException e) {
+            throw new InterruptedIOException();
+        }
+    }
+
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/FormBasedAuthInterceptor.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/FormBasedAuthInterceptor.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/FormBasedAuthInterceptor.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,103 @@
+/*
+ * 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.interceptors;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpException;
+import org.apache.http.HttpHost;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.NameValuePair;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.client.CredentialsProvider;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.cookie.Cookie;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.message.BasicNameValuePair;
+import org.apache.http.protocol.HttpContext;
+import org.apache.sling.testing.clients.util.ServerErrorRetryStrategy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.LinkedList;
+import java.util.List;
+
+public class FormBasedAuthInterceptor implements HttpRequestInterceptor {
+    static final Logger LOG = 
LoggerFactory.getLogger(FormBasedAuthInterceptor.class);
+
+    private final String loginPath = "j_security_check";
+    private final String loginTokenName;
+
+    public FormBasedAuthInterceptor(String loginTokenName) {
+        this.loginTokenName = loginTokenName;
+    }
+
+    public void process(HttpRequest request, HttpContext context) throws 
HttpException, IOException {
+        final URI uri = URI.create(request.getRequestLine().getUri());
+        if (uri.getPath().endsWith(loginPath)) {
+            LOG.debug("Request ends with {} so I'm not intercepting the 
request", loginPath);
+            return;
+        }
+
+        Cookie loginCookie = getLoginCookie(context, loginTokenName);
+        if (loginCookie != null) {
+            LOG.debug("Request has cookie {}={} so I'm not intercepting the 
request",
+                    loginCookie.getName(), loginCookie.getValue());
+            return;
+        }
+
+        // get host
+        final HttpHost host = HttpClientContext.adapt(context).getTargetHost();
+
+        // get the username and password from the credentials provider
+        final CredentialsProvider credsProvider = 
HttpClientContext.adapt(context).getCredentialsProvider();
+        final AuthScope scope = new AuthScope(host.getHostName(), 
host.getPort());
+        final String username = 
credsProvider.getCredentials(scope).getUserPrincipal().getName();
+        final String password = 
credsProvider.getCredentials(scope).getPassword();
+
+        List<NameValuePair> parameters = new LinkedList<>();
+        parameters.add(new BasicNameValuePair("j_username", username));
+        parameters.add(new BasicNameValuePair("j_password", password));
+        HttpEntity httpEntity = new UrlEncodedFormEntity(parameters, "utf-8");
+
+        HttpPost loginPost = new 
HttpPost(URI.create(request.getRequestLine().getUri()).resolve(loginPath));
+        loginPost.setEntity(httpEntity);
+
+        final CloseableHttpClient client = HttpClientBuilder.create()
+                .setServiceUnavailableRetryStrategy(new 
ServerErrorRetryStrategy())
+                .disableRedirectHandling()
+                .build();
+
+        client.execute(host, loginPost, context);
+
+    }
+
+    /** Get login token cookie or null if not found */
+    private Cookie getLoginCookie(HttpContext context, String loginTokenName) {
+        for (Cookie cookie : 
HttpClientContext.adapt(context).getCookieStore().getCookies()) {
+            if (cookie.getName().equalsIgnoreCase(loginTokenName) && 
!cookie.getValue().isEmpty()) {
+                return cookie;
+            }
+        }
+        return null;
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieHolder.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieHolder.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieHolder.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,40 @@
+/*******************************************************************************
+ * 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
+ * <p/>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p/>
+ * 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.interceptors;
+
+import org.apache.http.cookie.Cookie;
+import org.apache.sling.testing.clients.SystemPropertiesConfig;
+
+public class StickyCookieHolder {
+
+    private static final ThreadLocal<Cookie> testStickySessionCookie = new 
ThreadLocal<Cookie>();
+    public static final String COOKIE_NAME = 
System.getProperty(SystemPropertiesConfig.CONFIG_PROP_PREFIX + 
"session.cookie.name", "test_session_id");
+
+    public static Cookie getTestStickySessionCookie() {
+        return testStickySessionCookie.get();
+    }
+
+    public static void setTestStickySessionCookie(Cookie stickySessionCookie) {
+        testStickySessionCookie.set(stickySessionCookie);
+    }
+
+    public static void remove() {
+        testStickySessionCookie.remove();
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieInterceptor.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieInterceptor.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieInterceptor.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,61 @@
+/*
+ * 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.interceptors;
+
+
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.cookie.Cookie;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.protocol.HttpContext;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.ListIterator;
+
+public class StickyCookieInterceptor implements HttpRequestInterceptor {
+
+    public void process(HttpRequest httpRequest, HttpContext httpContext) 
throws HttpException, IOException {
+        final HttpClientContext clientContext = 
HttpClientContext.adapt(httpContext);
+        List<Cookie> cookies = clientContext.getCookieStore().getCookies();
+        boolean set = (null != 
StickyCookieHolder.getTestStickySessionCookie());
+        boolean found = false;
+        ListIterator<Cookie> it = cookies.listIterator();
+        while (it.hasNext()) {
+            Cookie cookie = it.next();
+            if (cookie.getName().equals(StickyCookieHolder.COOKIE_NAME)) {
+                found = true;
+                if (set) {
+                    // set the cookie with the value saved for each thread 
using the rule
+                    it.set(StickyCookieHolder.getTestStickySessionCookie());
+                } else {
+                    // if the cookie is not set in TestStickySessionRule, 
remove it from here
+                    it.remove();
+                }
+            }
+        }
+        // if the cookie needs to be set from TestStickySessionRule but did 
not exist in the client cookie list, add it here.
+        if (!found && set) {
+            cookies.add(StickyCookieHolder.getTestStickySessionCookie());
+        }
+        BasicCookieStore cs = new BasicCookieStore();
+        cs.addCookies(cookies.toArray(new Cookie[cookies.size()]));
+        httpContext.setAttribute(HttpClientContext.COOKIE_STORE, cs);
+    }
+}
\ No newline at end of file

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieSpec.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieSpec.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/StickyCookieSpec.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,47 @@
+/*
+ * 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.interceptors;
+
+import org.apache.http.Header;
+import org.apache.http.cookie.Cookie;
+import org.apache.http.cookie.CookieOrigin;
+import org.apache.http.cookie.CookiePathComparator;
+import org.apache.http.cookie.MalformedCookieException;
+import org.apache.http.impl.cookie.DefaultCookieSpec;
+
+import java.util.List;
+
+public class StickyCookieSpec extends DefaultCookieSpec {
+    private final static CookiePathComparator PATH_COMPARATOR = new 
CookiePathComparator();
+
+    @Override
+    public List<Cookie> parse(Header header, CookieOrigin origin) throws 
MalformedCookieException {
+        List<Cookie> cookies = super.parse(header, origin);
+        for (Cookie cookie : cookies) {
+            if (cookie.getName().equals(StickyCookieHolder.COOKIE_NAME)) {
+                // store it in the TestStickySessionRule threadlocal var
+                StickyCookieHolder.setTestStickySessionCookie(cookie);
+            }
+        }
+        return cookies;
+    }
+
+    @Override
+    public List<Header> formatCookies(List<Cookie> cookies) {
+        return super.formatCookies(cookies);
+    }
+}
\ No newline at end of file

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/TestDescriptionHolder.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/TestDescriptionHolder.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/TestDescriptionHolder.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,49 @@
+/*******************************************************************************
+ * 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
+ * <p/>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p/>
+ * 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.interceptors;
+
+public class TestDescriptionHolder {
+
+    private static final ThreadLocal<String> methodName = new 
ThreadLocal<String>();
+    private static final ThreadLocal<String> className = new 
ThreadLocal<String>();
+
+    public static String getMethodName() {
+        return methodName.get();
+    }
+
+    public static void setMethodName(String methodName) {
+        TestDescriptionHolder.methodName.set(methodName);
+    }
+
+    public static void removeMethodName() {
+        TestDescriptionHolder.methodName.remove();
+    }
+
+    public static String getClassName() {
+        return className.get();
+    }
+
+    public static void setClassName(String className) {
+        TestDescriptionHolder.className.set(className);
+    }
+
+    public static void removeClassName() {
+        TestDescriptionHolder.className.remove();
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/TestDescriptionInterceptor.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/TestDescriptionInterceptor.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/TestDescriptionInterceptor.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,49 @@
+/*
+ * 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.interceptors;
+
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpRequestInterceptor;
+import org.apache.http.protocol.HttpContext;
+
+import java.io.IOException;
+
+/**
+ * HttpClient interceptor that propagates the current test name as part HTTP 
request headers.
+ * Headers can then be logged, exported as MDC info etc. by {@code 
TestNameLoggingFilter}.
+ *
+ * Meant to help in correlating the server side logs with the test case being 
executed.
+ *
+ * @see org.slf4j.MDC http://www.slf4j.org/manual.html
+ */
+public class TestDescriptionInterceptor implements HttpRequestInterceptor{
+    //Same headers are defined in TestLogServlet
+    public static final String TEST_CLASS_HEADER = "X-Sling-TestClass";
+    public static final String TEST_NAME_HEADER = "X-Sling-TestName";
+
+    public void process(HttpRequest httpRequest, HttpContext httpContext) 
throws HttpException, IOException {
+        addHeader(httpRequest, TEST_NAME_HEADER, 
TestDescriptionHolder.getMethodName());
+        addHeader(httpRequest, TEST_CLASS_HEADER, 
TestDescriptionHolder.getClassName());
+    }
+
+    private static void addHeader(HttpRequest httpRequest, String name, String 
value){
+        if (value != null) {
+            httpRequest.addHeader(name, value);
+        }
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/package-info.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/package-info.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/interceptors/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("1.1.0")
+package org.apache.sling.testing.clients.interceptors;
+
+import org.osgi.annotation.versioning.Version;

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/Bundle.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/Bundle.java 
(added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/Bundle.java 
Thu Apr  9 13:50:42 2020
@@ -0,0 +1,55 @@
+/*
+ * 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;
+
+/**
+ * Thin Wrapper around a Bundle definition JSON
+ */
+public class Bundle {
+
+    public enum Status {
+
+        ACTIVE("Active"),
+
+        FRAGMENT("Fragment"),
+
+        RESOLVED("Resolved"),
+
+        INSTALLED("Installed");
+
+        String value;
+
+        Status(String value) {
+            this.value = value;
+        }
+
+        public static Status value(String o) {
+            for(Status s : values()) {
+                if(s.value.equalsIgnoreCase(o)) {
+                    return s;
+                }
+            }
+            return null;
+        }
+
+        public String toString() {
+            return value;
+        }
+    }
+
+}
\ No newline at end of file

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundleInfo.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundleInfo.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundleInfo.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,124 @@
+/*
+ * 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.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+public class BundleInfo {
+
+    private JsonNode bundle;
+
+    public BundleInfo(JsonNode root) throws ClientException {
+        if(root.get("id") != null) {
+            if(root.get("id") == null) {
+                throw new ClientException("No Bundle Info returned");
+            }
+            bundle = root;
+        } else {
+            if(root.get("data") == null && root.get("data").size() < 1) {
+                throw new ClientException("No Bundle Info returned");
+            }
+            bundle = root.get("data").get(0);
+        }
+    }
+
+    /**
+     * @return the bundle identifier
+     */
+    public int getId() {
+        return bundle.get("id").getIntValue();
+    }
+
+    /**
+     * @return the bundle name
+     */
+    public String getName() {
+        return bundle.get("name").getTextValue();
+    }
+
+    /**
+     * @return the bundle version
+     */
+    public String getVersion() {
+        return bundle.get("version").getTextValue();
+    }
+
+    /**
+     * Returns the indicator if the bundle is a fragment
+     * 
+     * @return {@code true} if bundle is a fragment, {@code false} otherwise.
+     */
+    public boolean isFragment() {
+        return bundle.get("fragment").getBooleanValue();
+    }
+
+    /**
+     * @return the bundle current state
+     */
+    public Bundle.Status getStatus() {
+        return Bundle.Status.value(bundle.get("state").getTextValue());
+    }
+
+    /**
+     * @return the bundle symbolic name
+     */
+    public String getSymbolicName() {
+        return bundle.get("symbolicName").getTextValue();
+    }
+
+    /**
+     * @return the category of the bundle
+     */
+    public String getCategory() {
+        return bundle.get("category").getTextValue();
+    }
+
+    /**
+     * Returns the value of a specific key in the bundle
+     *
+     * @param key the property to search
+     * @return a specific bundle property
+     */
+    public String getProperty(String key) {
+        Map<String, String> props = getProperties();
+        return props.get(key);
+    }
+
+    /**
+     * @return the bundle properties in a {@link Map}
+     */
+    public Map<String, String> getProperties() {
+        JsonNode props = bundle.get("props");
+        Map<String, String> entries = new HashMap<String, String>();
+
+        if(props != null) {
+            Iterator<JsonNode> it = props.getElements();
+            while(it.hasNext()) {
+                JsonNode n = it.next();
+                entries.put(n.get("key").getTextValue(), 
n.get("value").getTextValue());
+            }
+        }
+        return entries;
+    }
+
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundlesInfo.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundlesInfo.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundlesInfo.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.Iterator;
+
+/**
+ * A simple Wrapper around the returned JSON when requesting the status of 
/system/console/bundles
+ */
+public class BundlesInfo {
+
+    private JsonNode root = null;
+
+    private JsonNode status = 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 BundlesInfo(JsonNode root) throws ClientException {
+        this.root = root;
+        // some simple sanity checks
+        if(root.get("s") == null)
+            throw new ClientException("No Status Info returned!");
+        if(root.get("s").size() != 5)
+            throw new ClientException("Wrong number of status numbers 
listed!");
+        status = root.get("s");
+    }
+
+    /**
+     * @return the status message of the bundle context
+     * @throws ClientException if the request cannot be completed
+     */
+    public String getStatusMessage() throws ClientException {
+        if(root.get("status") == null)
+            throw new ClientException("No Status message returned!");
+        return root.get("status").getValueAsText();
+    }
+
+    /**
+     * @return total number of bundles.
+     */
+    public int getTotalNumOfBundles() {
+        return Integer.parseInt(status.get(0).getValueAsText());
+    }
+
+    /**
+     * Returns number of bundles that are in specified state
+     *
+     * @param status the requested status
+     * @return the number of bundles
+     */
+    public int getNumBundlesByStatus(Bundle.Status status) {
+        int index = -1;
+        switch(status) {
+        case ACTIVE:
+            index = 1;
+            break;
+        case FRAGMENT:
+            index = 2;
+            break;
+        case RESOLVED:
+            index = 3;
+            break;
+        case INSTALLED:
+            index = 4;
+            break;
+        }
+        return Integer.parseInt(this.status.get(index).getValueAsText());
+    }
+
+    /**
+     * Return bundle info for a bundle with persistence identifier {@code pid}
+     *
+     * @param id the id of the bundle
+     * @return the BundleInfo
+     * @throws ClientException if the info could not be retrieved
+     */
+    public BundleInfo forId(String id) throws ClientException {
+        JsonNode bundle = findBy("id", id);
+        return (bundle != null) ? new BundleInfo(bundle) : null;
+    }
+
+    /**
+     * Return bundle info for a bundle with name {@code name}
+     *
+     * @param name the name of the requested bundle
+     * @return the info, or {@code null} if the bundle is not found
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public BundleInfo forName(String name) throws ClientException {
+        JsonNode bundle = findBy("name", name);
+        return (bundle != null) ? new BundleInfo(bundle) : null;
+    }
+
+    /**
+     * Return bundle info for a bundle with symbolic name {@code name}
+     *
+     * @param name the symbolic name of the requested bundle
+     * @return the info, or {@code null} if the bundle is not found
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public BundleInfo forSymbolicName(String name) throws ClientException {
+        JsonNode bundle = findBy("symbolicName", name);
+        return (bundle != null) ? new BundleInfo(bundle) : null;
+    }
+
+    private JsonNode findBy(String key, String value) {
+        Iterator<JsonNode> nodes = root.get("data").getElements();
+        while(nodes.hasNext()) {
+            JsonNode node = nodes.next();
+            if ((null != node.get(key)) && (node.get(key).isValueNode())) {
+                final String valueNode = node.get(key).getTextValue();
+                if (valueNode.equals(value)) {
+                    return node;
+                }
+            }
+        }
+        return null;
+    }
+
+}
\ No newline at end of file

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundlesInstaller.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundlesInstaller.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/BundlesInstaller.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,208 @@
+/*
+ * 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.poller.Polling;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+
+
+/**
+ * Utility for installing and starting additional bundles for testing
+ */
+public class BundlesInstaller {
+    private final Logger log = LoggerFactory.getLogger(getClass());
+    private final OsgiConsoleClient osgiConsoleClient;
+    public static final String ACTIVE_STATE = "active";
+
+    public BundlesInstaller(OsgiConsoleClient cc) {
+        osgiConsoleClient = cc;
+    }
+
+    /**
+     * Checks if a bundle is installed or not. Does not retry.
+     * @param bundleFile bundle file
+     * @return true if the bundle is installed
+     * @throws ClientException if the state of the bundle could not be 
determined
+     */
+    public boolean isInstalled(File bundleFile) throws ClientException {
+        String bundleSymbolicName = "";
+        try {
+            bundleSymbolicName = 
OsgiConsoleClient.getBundleSymbolicName(bundleFile);
+            log.debug("Checking if installed: " + bundleSymbolicName);
+
+            osgiConsoleClient.getBundleState(bundleSymbolicName);
+            log.debug("Already installed: " + bundleSymbolicName);
+            return true;
+        } catch (ClientException e) {
+            log.debug("Not yet installed: " + bundleSymbolicName);
+            return false;
+        } catch (IOException e) {
+            log.debug("Failed to retrieve bundle symbolic name from file. ", 
e);
+            throw new ClientException("Failed to retrieve bundle symbolic name 
from file. ", e);
+        }
+    }
+
+    /**
+     * Check if the installed version matches the one of the bundle (file)
+     * @param bundleFile bundle file
+     * @return true if the bundle is installed and has the same version
+     * @throws ClientException if the installed version cannot be retrieved
+     * @throws IOException if the file version cannot be read
+     */
+    public boolean isInstalledWithSameVersion(File bundleFile) throws 
ClientException, IOException {
+        final String bundleSymbolicName = 
OsgiConsoleClient.getBundleSymbolicName(bundleFile);
+        final String versionOnServer = 
osgiConsoleClient.getBundleVersion(bundleSymbolicName);
+        final String versionInBundle = 
OsgiConsoleClient.getBundleVersionFromFile(bundleFile);
+        if (versionOnServer.equals(versionInBundle)) {
+            return true;
+        } else {
+            log.warn("Installed bundle doesn't match: {}, versionOnServer={}, 
versionInBundle={}",
+                    bundleSymbolicName, versionOnServer, versionInBundle);
+            return false;
+        }
+    }
+
+    /**
+     * Install a list of bundles supplied as Files
+     * @param toInstall list ob bundles to install
+     * @param startBundles whether to start the bundles
+     * @throws ClientException if an error occurs during installation
+     * @throws IOException if reading the file fails
+     */
+    public void installBundles(List<File> toInstall, boolean startBundles) 
throws ClientException, IOException {
+        for(File f : toInstall) {
+            final String bundleSymbolicName = 
OsgiConsoleClient.getBundleSymbolicName(f);
+            if (isInstalled(f)) {
+                if (f.getName().contains("SNAPSHOT")) {
+                    log.info("Reinstalling (due to SNAPSHOT version): {}", 
bundleSymbolicName);
+                    osgiConsoleClient.uninstallBundle(bundleSymbolicName);
+                } else if (!isInstalledWithSameVersion(f)) {
+                    log.info("Reinstalling (due to version mismatch): {}", 
bundleSymbolicName);
+                    osgiConsoleClient.uninstallBundle(bundleSymbolicName);
+                } else {
+                    log.info("Not reinstalling: {}", bundleSymbolicName);
+                    continue;
+                }
+            }
+            osgiConsoleClient.installBundle(f, startBundles);
+            log.info("Installed: {}", bundleSymbolicName);
+        }
+
+        // ensure that bundles are re-wired esp. if an existing bundle was 
updated
+        osgiConsoleClient.refreshPackages();
+
+        log.info("{} additional bundles installed", toInstall.size());
+    }
+
+    /**
+     * Uninstall a list of bundles supplied as Files
+     * @param toUninstall bundles to uninstall
+     * @throws ClientException if one of the requests failed
+     * @throws IOException if the files cannot be read from disk
+     */
+    public void uninstallBundles(List<File> toUninstall) throws 
ClientException, IOException {
+        for(File f : toUninstall) {
+            final String bundleSymbolicName = 
OsgiConsoleClient.getBundleSymbolicName(f);
+            if (isInstalled(f)) {
+                log.info("Uninstalling bundle: {}", bundleSymbolicName);
+                osgiConsoleClient.uninstallBundle(bundleSymbolicName);
+            } else {
+                log.info("Could not uninstall: {} as it never was installed", 
bundleSymbolicName);
+            }
+        }
+
+        // ensure that bundles are re-wired esp. if an existing bundle was 
updated
+        osgiConsoleClient.refreshPackages();
+
+        log.info("{} additional bundles uninstalled", toUninstall.size());
+    }
+
+
+    /**
+     * Wait for all bundles specified in symbolicNames list to be installed in 
the OSGi web console.
+     * @deprecated use {@link #waitBundlesInstalled(List, long)}
+     * @param symbolicNames the list of names for the bundles
+     * @param timeoutSeconds how many seconds to wait
+     * @throws ClientException if something went wrong
+     * @throws InterruptedException if interrupted
+     * @return true if all the bundles were installed
+     */
+    @Deprecated
+    public boolean waitForBundlesInstalled(List<String> symbolicNames, int 
timeoutSeconds) throws ClientException, InterruptedException {
+        log.info("Checking that the following bundles are installed (timeout 
{} seconds): {}", timeoutSeconds, symbolicNames);
+        for (String symbolicName : symbolicNames) {
+            boolean started = 
osgiConsoleClient.checkBundleInstalled(symbolicName, 500, 2 * timeoutSeconds);
+            if (!started) return false;
+        }
+        return true;
+    }
+
+    /**
+     * Wait for multiple bundles to be installed in the OSGi web console.
+     * @param symbolicNames the list bundles to be checked
+     * @param timeout max total time to wait for all bundles, in ms, before 
throwing {@code TimeoutException}
+     * @throws TimeoutException if the timeout was reached before all the 
bundles were installed
+     * @throws InterruptedException to mark this operation as "waiting", 
callers should rethrow it
+     */
+    public void waitBundlesInstalled(List<String> symbolicNames, long timeout)
+            throws InterruptedException, TimeoutException {
+        log.info("Checking that the following bundles are installed (timeout 
{} ms): {}", timeout, symbolicNames);
+        long start = System.currentTimeMillis();
+        for (String symbolicName : symbolicNames) {
+            osgiConsoleClient.waitBundleInstalled(symbolicName, timeout, 500);
+
+            if (System.currentTimeMillis() > start + timeout) {
+                throw new TimeoutException("Waiting for bundles did not finish 
in " + timeout + " ms.");
+            }
+        }
+    }
+
+    /**
+     * Start all the bundles in a {{List}}
+     * @param symbolicNames the list of bundles to start
+     * @param timeout total max time to wait for all the bundles, in ms
+     * @throws TimeoutException if the timeout is reached before all the 
bundles are started
+     * @throws InterruptedException to mark this operation as "waiting", 
callers should rethrow it
+     */
+    public void startAllBundles(final List<String> symbolicNames, int timeout) 
throws InterruptedException, TimeoutException {
+        log.info("Starting bundles (timeout {} seconds): {}", timeout, 
symbolicNames);
+
+        Polling p = new Polling() {
+            @Override
+            public Boolean call() throws Exception {
+                boolean allActive = true;
+                for (String bundle : symbolicNames) {
+                    String state = osgiConsoleClient.getBundleState(bundle);
+                    if (!state.equalsIgnoreCase(ACTIVE_STATE)) {
+                        osgiConsoleClient.startBundle(bundle);
+                        allActive = false;
+                    }
+                }
+                return allActive;
+            }
+        };
+
+        p.poll(timeout, 500);
+    }
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/Component.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/Component.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/Component.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * licenses this file to You under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations 
under
+ * the License.
+ */
+
+package org.apache.sling.testing.clients.osgi;
+
+public class Component {
+
+    public enum Status {
+
+        // the states being used in the DS Felix WebConsole are listed in 
https://github.com/apache/felix/blob/6e5cde8471febb36bc72adeba85989edba943188/webconsole-plugins/ds/src/main/java/org/apache/felix/webconsole/plugins/ds/internal/ComponentConfigurationPrinter.java#L374
+        ACTIVE("active"),
+
+        SATISFIED("satisfied"),
+
+        UNSATISFIED_CONFIGURATION("unsatisfied (configuration)"),
+
+        UNSATISFIED_REFERENCE("unsatisfied (reference)"),
+
+        FAILED_ACTIVATION("failed activation"),
+
+        UNKNOWN("unknown");
+
+        String value;
+
+        Status(String value) {
+            this.value = value;
+        }
+
+        public static Status value(String o) {
+            for(Status s : values()) {
+                if(s.value.equalsIgnoreCase(o)) {
+                    return s;
+                }
+            }
+            return UNKNOWN;
+        }
+
+        public String toString() {
+            return value;
+        }
+
+    }
+
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ComponentInfo.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ComponentInfo.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ComponentInfo.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.osgi;
+
+import org.apache.sling.testing.clients.ClientException;
+import org.codehaus.jackson.JsonNode;
+
+public class ComponentInfo {
+
+    private JsonNode component;
+
+    public ComponentInfo(JsonNode root) throws ClientException {
+        if(root.get("id") != null) {
+            if(root.get("id") == null) {
+                throw new ClientException("No Component Info returned");
+            }
+            component = root;
+        } else {
+            if(root.get("data") == null && root.get("data").size() < 1) {
+                throw new ClientException("No Component Info returned");
+            }
+            component = root.get("data").get(0);
+        }
+    }
+
+    /**
+     * @return the component identifier
+     */
+    public int getId() {
+        return component.get("id").getIntValue();
+    }
+
+    /**
+     * @return the component name
+     */
+    public String getName() {
+        return component.get("name").getTextValue();
+    }
+
+    /**
+     * @return the component status
+     */
+    public Component.Status getStatus() {
+        return Component.Status.value(component.get("state").getTextValue());
+    }
+
+    /**
+     * @return the component persistent identifier
+     */
+    public String getPid() {
+        return component.get("pid").getTextValue();
+    }
+
+}

Added: 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ComponentsInfo.java
==============================================================================
--- 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ComponentsInfo.java
 (added)
+++ 
release/sling/src/main/java/org/apache/sling/testing/clients/osgi/ComponentsInfo.java
 Thu Apr  9 13:50:42 2020
@@ -0,0 +1,95 @@
+/*
+ * 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.Iterator;
+
+/**
+ * Thin wrapper around the list of components
+ */
+public class ComponentsInfo {
+
+    private JsonNode root = null;
+
+    /**
+     * The only constructor.
+     * 
+     * @param rootNode the root JSON node of the components info.
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public ComponentsInfo(JsonNode rootNode) throws ClientException {
+        this.root = rootNode;
+    }
+
+    /**
+     * @return the number of installed components
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public int getNumberOfInstalledComponents() throws ClientException {
+        if(root.get("status") == null)
+            throw new ClientException("Number of installed Components not 
defined!");
+        return Integer.parseInt(root.get("status").getValueAsText());
+    }
+
+    /**
+     * @param id the id of the component
+     * @return the ComponentInfo for a component with the identifier {@code id}
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public ComponentInfo forId(String id) throws ClientException {
+        JsonNode component = findBy("id", id);
+        return (component != null) ? new ComponentInfo(component) : null;
+    }
+
+    /**
+     * @param name the name of the component
+     * @return the ComponentInfo for a component with the name {@code name}
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public ComponentInfo forName(String name) throws ClientException {
+        JsonNode component = findBy("name", name);
+        return (component != null) ? new ComponentInfo(component) : null;
+    }
+
+    /**
+     * @param pid the pid of the component
+     * @return the ComponentInfo for a component with the pid {@code pid}
+     * @throws ClientException if the info cannot be retrieved
+     */
+    public ComponentInfo forPid(String pid) throws ClientException {
+        JsonNode component = findBy("pid", pid);
+        return (component != null) ? new ComponentInfo(component) : null;
+    }
+
+    private JsonNode findBy(String key, String value) {
+        Iterator<JsonNode> nodes = root.get("data").getElements();
+        while(nodes.hasNext()) {
+            JsonNode node = nodes.next();
+            if ((null != node.get(key)) && (node.get(key).isValueNode())) {
+                final String valueNode = node.get(key).getTextValue();
+                if (valueNode.equals(value)) {
+                    return node;
+                }
+            }
+        }
+        return null;
+    }
+}
\ No newline at end of file


Reply via email to