SLING-7297 add methods to wait for a component and a service
diff --git a/pom.xml b/pom.xml
index ee4d472..17c4562 100644
--- a/pom.xml
+++ b/pom.xml
@@ -141,6 +141,12 @@
             <scope>test</scope>
         </dependency>
         <dependency>
+            <groupId>org.hamcrest</groupId>
+            <artifactId>hamcrest-library</artifactId>
+            <version>1.3</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
             <groupId>org.apache.sling</groupId>
             <artifactId>org.apache.sling.hapi.client</artifactId>
             <version>1.0.0</version>
diff --git a/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java b/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java
index 55bdb25..1e8fac4 100644
--- a/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java
+++ b/src/main/java/org/apache/sling/testing/clients/osgi/OsgiConsoleClient.java
@@ -39,6 +39,7 @@
 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;
@@ -77,6 +78,11 @@
      * 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";
@@ -162,6 +168,105 @@
         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".
+     * @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.REGISTERED) || (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
     //
@@ -462,12 +567,12 @@
     }
 
     /**
-     * Install a bundle using the Felix webconsole HTTP interface and wait for it to be installed
+     * 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 much to wait for the bundle to be installed before throwing a {@code TimeoutException}
-     * @param delay time to wait between checks of the state
+     * @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
@@ -484,12 +589,13 @@
     }
 
     /**
-     * Wait until the bundle is installed
+     * Wait until the bundle is installed.
      * @param symbolicName symbolic name of bundle
-     * @param timeout how much to wait for the bundle to be installed before throwing a {@code TimeoutException}
-     * @param delay time to wait between checks of the state
+     * @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 {
@@ -503,7 +609,40 @@
 
             @Override
             protected String message() {
-                return "Bundle " + symbolicName + " did not install in %1$ ms";
+                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";
             }
         };
 
diff --git a/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java b/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java
new file mode 100644
index 0000000..8df6463
--- /dev/null
+++ b/src/main/java/org/apache/sling/testing/clients/osgi/ServiceInfo.java
@@ -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();
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java b/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java
new file mode 100644
index 0000000..8ec3bbd
--- /dev/null
+++ b/src/main/java/org/apache/sling/testing/clients/osgi/ServicesInfo.java
@@ -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
diff --git a/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java b/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java
index 2b92f60..fa38949 100644
--- a/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java
+++ b/src/main/java/org/apache/sling/testing/clients/osgi/package-info.java
@@ -19,7 +19,7 @@
 /**
  * OSGI testing tools.
  */
-@Version("1.3.0")
+@Version("1.4.0")
 package org.apache.sling.testing.clients.osgi;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/src/main/java/org/apache/sling/testing/clients/util/poller/Polling.java b/src/main/java/org/apache/sling/testing/clients/util/poller/Polling.java
index c57018d..064bbcc 100644
--- a/src/main/java/org/apache/sling/testing/clients/util/poller/Polling.java
+++ b/src/main/java/org/apache/sling/testing/clients/util/poller/Polling.java
@@ -121,7 +121,7 @@
     /**
      * Returns the string to be used in the {@code TimeoutException}, if needed.
      * The string is passed to {@code String.format(message(), timeout, delay)}, so it can be a format
-     * including {@code %1$} and {@code %2$}. The field {@code lastException} is also available for logging
+     * including {@code %1$d} and {@code %2$d}. The field {@code lastException} is also available for logging
      *
      * @return the format string
      */
diff --git a/src/test/java/org/apache/sling/testing/clients/osgi/ServicesInfoTest.java b/src/test/java/org/apache/sling/testing/clients/osgi/ServicesInfoTest.java
new file mode 100644
index 0000000..b74bb31
--- /dev/null
+++ b/src/test/java/org/apache/sling/testing/clients/osgi/ServicesInfoTest.java
@@ -0,0 +1,34 @@
+/*
+ * 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.hamcrest.Matchers;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class ServicesInfoTest {
+
+    @Test
+    public void testSplitPseudoJsonValueArray() {
+        Assert.assertThat(ServicesInfo.splitPseudoJsonValueArray("test"), Matchers.contains("test"));
+        Assert.assertThat(ServicesInfo.splitPseudoJsonValueArray("[]"), Matchers.contains(""));
+        Assert.assertThat(ServicesInfo.splitPseudoJsonValueArray("[one, two]"), Matchers.contains("one", "two"));
+        Assert.assertThat(ServicesInfo.splitPseudoJsonValueArray("[one,two]"), Matchers.contains("one", "two"));
+        Assert.assertThat(ServicesInfo.splitPseudoJsonValueArray("[java.lang.Runnable, org.apache.sling.event.impl.jobs.queues.QueueManager, org.osgi.service.event.EventHandler]"),
+                Matchers.contains("java.lang.Runnable", "org.apache.sling.event.impl.jobs.queues.QueueManager", "org.osgi.service.event.EventHandler"));
+    }
+}