All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.appium.java_client.service.local.AppiumDriverLocalService Maven / Gradle / Ivy

There is a newer version: 9.3.0
Show newest version
/*
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 * 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 io.appium.java_client.service.local;

import com.google.common.annotations.VisibleForTesting;
import io.appium.java_client.internal.ReflectionHelpers;
import lombok.SneakyThrows;
import org.openqa.selenium.net.UrlChecker;
import org.openqa.selenium.os.CommandLine;
import org.openqa.selenium.remote.service.DriverService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

import javax.annotation.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.isNullOrEmpty;
import static io.appium.java_client.service.local.AppiumServiceBuilder.BROADCAST_IP4_ADDRESS;
import static io.appium.java_client.service.local.AppiumServiceBuilder.BROADCAST_IP6_ADDRESS;
import static org.slf4j.event.Level.DEBUG;
import static org.slf4j.event.Level.INFO;

public final class AppiumDriverLocalService extends DriverService {

    private static final String URL_MASK = "http://%s:%d/";
    private static final Logger LOG = LoggerFactory.getLogger(AppiumDriverLocalService.class);
    private static final Pattern LOGGER_CONTEXT_PATTERN = Pattern.compile("^(\\[debug\\] )?\\[(.+?)\\]");
    private static final String APPIUM_SERVICE_SLF4J_LOGGER_PREFIX = "appium.service";
    private static final Duration DESTROY_TIMEOUT = Duration.ofSeconds(60);
    private static final Duration IS_RUNNING_PING_TIMEOUT = Duration.ofMillis(1500);

    private final File nodeJSExec;
    private final List nodeJSArgs;
    private final Map nodeJSEnvironment;
    private final Duration startupTimeout;
    private final ReentrantLock lock = new ReentrantLock(true); //uses "fair" thread ordering policy
    private final ListOutputStream stream = new ListOutputStream().add(System.out);
    private final URL url;
    private String basePath;

    private CommandLine process = null;

    AppiumDriverLocalService(String ipAddress, File nodeJSExec,
                             int nodeJSPort, Duration startupTimeout,
                             List nodeJSArgs, Map nodeJSEnvironment
    ) throws IOException {
        super(nodeJSExec, nodeJSPort, startupTimeout, nodeJSArgs, nodeJSEnvironment);
        this.nodeJSExec = nodeJSExec;
        this.nodeJSArgs = nodeJSArgs;
        this.nodeJSEnvironment = nodeJSEnvironment;
        this.startupTimeout = startupTimeout;
        this.url = new URL(String.format(URL_MASK, ipAddress, nodeJSPort));
    }

    public static AppiumDriverLocalService buildDefaultService() {
        return buildService(new AppiumServiceBuilder());
    }

    public static AppiumDriverLocalService buildService(AppiumServiceBuilder builder) {
        return builder.build();
    }

    public AppiumDriverLocalService withBasePath(String basePath) {
        this.basePath = basePath;
        return this;
    }

    public String getBasePath() {
        return this.basePath;
    }

    @SneakyThrows
    private static URL addSuffix(URL url, String suffix) {
        return url.toURI().resolve("." + (suffix.startsWith("/") ? suffix : "/" + suffix)).toURL();
    }

    @SneakyThrows
    @SuppressWarnings("SameParameterValue")
    private static URL replaceHost(URL source, String oldHost, String newHost) {
        return new URL(source.toString().replaceFirst(oldHost, newHost));
    }

    /**
     * Base URL.
     *
     * @return The base URL for the managed appium server.
     */
    @Override
    public URL getUrl() {
        return basePath == null ? url : addSuffix(url, basePath);
    }

    @Override
    public boolean isRunning() {
        lock.lock();
        try {
            if (process == null || !process.isRunning()) {
                return false;
            }

            try {
                ping(IS_RUNNING_PING_TIMEOUT);
                return true;
            } catch (UrlChecker.TimeoutException e) {
                return false;
            } catch (MalformedURLException e) {
                throw new AppiumServerHasNotBeenStartedLocallyException(e.getMessage(), e);
            }
        } finally {
            lock.unlock();
        }

    }

    private void ping(Duration timeout) throws UrlChecker.TimeoutException, MalformedURLException {
        URL baseURL = getUrl();
        String host = baseURL.getHost();
        // The operating system will block direct access to the universal broadcast IP address
        if (host.equals(BROADCAST_IP4_ADDRESS)) {
            baseURL = replaceHost(baseURL, BROADCAST_IP4_ADDRESS, "127.0.0.1");
        } else if (host.equals(BROADCAST_IP6_ADDRESS)) {
            baseURL = replaceHost(baseURL, BROADCAST_IP6_ADDRESS, "::1");
        }
        URL status = addSuffix(baseURL, "/status");
        new UrlChecker().waitUntilAvailable(timeout.toMillis(), TimeUnit.MILLISECONDS, status);
    }

    /**
     * Starts the defined appium server.
     *
     * @throws AppiumServerHasNotBeenStartedLocallyException If an error occurs while spawning the child process.
     * @see #stop()
     */
    @Override
    public void start() throws AppiumServerHasNotBeenStartedLocallyException {
        lock.lock();
        try {
            if (isRunning()) {
                return;
            }

            try {
                process = new CommandLine(
                        this.nodeJSExec.getCanonicalPath(),
                        nodeJSArgs.toArray(new String[]{})
                );
                process.setEnvironmentVariables(nodeJSEnvironment);
                process.copyOutputTo(stream);
                process.executeAsync();
                ping(startupTimeout);
            } catch (Exception e) {
                final Optional output = Optional.ofNullable(process)
                        .map(CommandLine::getStdOut)
                        .filter(o -> !isNullOrEmpty(o));
                destroyProcess();
                List errorLines = new ArrayList<>();
                errorLines.add("The local appium server has not been started");
                errorLines.add(String.format("Reason: %s", e.getMessage()));
                if (e instanceof UrlChecker.TimeoutException) {
                    errorLines.add(String.format(
                            "Consider increasing the server startup timeout value (currently %sms)",
                            startupTimeout.toMillis()
                    ));
                }
                errorLines.add(
                        String.format("Node.js executable path: %s", nodeJSExec.getAbsolutePath())
                );
                errorLines.add(String.format("Arguments: %s", nodeJSArgs));
                output.ifPresent(o -> errorLines.add(String.format("Output: %s", o)));
                throw new AppiumServerHasNotBeenStartedLocallyException(
                        String.join("\n", errorLines), e
                );
            }
        } finally {
            lock.unlock();
        }
    }

    /**
     * Stops this service is it is currently running. This method will attempt to block until the
     * server has been fully shutdown.
     *
     * @see #start()
     */
    @Override
    public void stop() {
        lock.lock();
        try {
            if (process != null) {
                destroyProcess();
            }
            process = null;
        } finally {
            lock.unlock();
        }
    }

    /**
     * Destroys the service if it is running.
     *
     * @param timeout The maximum time to wait before the process will be force-killed.
     * @return The exit code of the process or zero if the process was not running.
     */
    private int destroyProcess(Duration timeout) {
        if (process == null || !process.isRunning()) {
            return 0;
        }

        // This all magic is necessary, because Selenium does not publicly expose
        // process killing timeouts. By default a process is killed forcibly if
        // it does not exit after two seconds, which is in most cases not enough for
        // Appium
        try {
            Object osProcess = ReflectionHelpers.getPrivateFieldValue(
                    process.getClass(), process, "process", Object.class
            );
            Object watchdog = ReflectionHelpers.getPrivateFieldValue(
                    osProcess.getClass(), osProcess, "executeWatchdog", Object.class
            );
            Process nativeProcess = ReflectionHelpers.getPrivateFieldValue(
                    watchdog.getClass(), watchdog, "process", Process.class
            );
            nativeProcess.destroy();
            nativeProcess.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (Exception e) {
            LOG.warn("No explicit timeout could be applied to the process termination", e);
        }

        return process.destroy();
    }

    /**
     * Destroys the service.
     * This methods waits up to `DESTROY_TIMEOUT` seconds for the Appium service
     * to exit gracefully.
     */
    private void destroyProcess() {
        destroyProcess(DESTROY_TIMEOUT);
    }

    /**
     * Logs as string.
     *
     * @return String logs if the server has been run. Null is returned otherwise.
     */
    @Nullable
    public String getStdOut() {
        if (process != null) {
            return process.getStdOut();
        }

        return null;
    }

    /**
     * Adds other output stream which should accept server output data.
     *
     * @param outputStream is an instance of {@link OutputStream}
     *                     that is ready to accept server output
     */
    public void addOutPutStream(OutputStream outputStream) {
        checkNotNull(outputStream, "outputStream parameter is NULL!");
        stream.add(outputStream);
    }

    /**
     * Adds other output streams which should accept server output data.
     *
     * @param outputStreams is a list of additional {@link OutputStream}
     *                      that are ready to accept server output
     */
    public void addOutPutStreams(List outputStreams) {
        checkNotNull(outputStreams, "outputStreams parameter is NULL!");
        for (OutputStream outputStream : outputStreams) {
            addOutPutStream(outputStream);
        }
    }

    /**
     * Remove the outputStream which is receiving server output data.
     *
     * @return the outputStream has been removed if it is present
     */
    public Optional removeOutPutStream(OutputStream outputStream) {
        checkNotNull(outputStream, "outputStream parameter is NULL!");
        return stream.remove(outputStream);
    }

    /**
     * Remove all existing server output streams.
     *
     * @return true if at least one output stream has been cleared
     */
    public boolean clearOutPutStreams() {
        return stream.clear();
    }

    /**
     * Enables server output data logging through
     * SLF4J loggers. This allow server output
     * data to be configured with your preferred logging frameworks (e.g.
     * java.util.logging, logback, log4j).
     *
     * 

NOTE1: You might want to call method {@link #clearOutPutStreams()} before * calling this method.
* NOTE2: it is required that {@code --log-timestamp} server flag is * {@code false}. * *

By default log messages are: *

    *
  • logged at {@code INFO} level, unless log message is pre-fixed by * {@code [debug]} then logged at {@code DEBUG} level.
  • *
  • logged by a SLF4J logger instance with * a name corresponding to the appium sub module as prefixed in log message * (logger name is transformed to lower case, no spaces and prefixed with * "appium.service.").
  • *
* Example log-message: "[ADB] Cannot read version codes of " is logged by * logger: {@code appium.service.adb} at level {@code INFO}. *
* Example log-message: "[debug] [XCUITest] Xcode version set to 'x.y.z' " * is logged by logger {@code appium.service.xcuitest} at level * {@code DEBUG}. *
* * @see #addSlf4jLogMessageConsumer(BiConsumer) */ public void enableDefaultSlf4jLoggingOfOutputData() { addSlf4jLogMessageConsumer((logMessage, ctx) -> { if (ctx.getLevel().equals(DEBUG)) { ctx.getLogger().debug(logMessage); } else { ctx.getLogger().info(logMessage); } }); } /** * When a complete log message is available (from server output data) that * message is parsed for its slf4j context (logger name, logger level etc.) * and the specified {@code BiConsumer} is invoked with the log message and * slf4j context. * *

Use this method only if you want a behavior that differentiates from the * default behavior as enabled by method * {@link #enableDefaultSlf4jLoggingOfOutputData()}. * *

NOTE: You might want to call method {@link #clearOutPutStreams()} before * calling this method. * *

implementation detail: *

    *
  • if log message begins with {@code [debug]} then log level is set to * {@code DEBUG}, otherwise log level is {@code INFO}.
  • *
  • the appium sub module name is parsed from the log message and used as * logger name (prefixed with "appium.service.", all lower case, spaces * removed). If no appium sub module is detected then "appium.service" is * used as logger name.
  • *
* Example log-message: "[ADB] Cannot read version codes of " is logged by * {@code appium.service.adb} at level {@code INFO}
* Example log-message: "[debug] [XCUITest] Xcode version set to 'x.y.z' " * is logged by {@code appium.service.xcuitest} at level {@code DEBUG} *
* * @param slf4jLogMessageConsumer BiConsumer block to be executed when a log message is * available. */ public void addSlf4jLogMessageConsumer(BiConsumer slf4jLogMessageConsumer) { checkNotNull(slf4jLogMessageConsumer, "slf4jLogMessageConsumer parameter is NULL!"); addLogMessageConsumer(logMessage -> { slf4jLogMessageConsumer.accept(logMessage, parseSlf4jContextFromLogMessage(logMessage)); }); } @VisibleForTesting static Slf4jLogMessageContext parseSlf4jContextFromLogMessage(String logMessage) { Matcher m = LOGGER_CONTEXT_PATTERN.matcher(logMessage); String loggerName = APPIUM_SERVICE_SLF4J_LOGGER_PREFIX; Level level = INFO; if (m.find()) { loggerName += "." + m.group(2).toLowerCase().replaceAll("\\s+", ""); if (m.group(1) != null) { level = DEBUG; } } return new Slf4jLogMessageContext(loggerName, level); } /** * When a complete log message is available (from server output data), the * specified {@code Consumer} is invoked with that log message. * *

NOTE: You might want to call method {@link #clearOutPutStreams()} before * calling this method. * *

If the Consumer fails and throws an exception the exception is logged (at * WARN level) and execution continues. *
* * @param consumer Consumer block to be executed when a log message is available. */ public void addLogMessageConsumer(Consumer consumer) { checkNotNull(consumer, "consumer parameter is NULL!"); addOutPutStream(new OutputStream() { private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); @Override public void write(int chr) { try { outputStream.write(chr); if (chr == '\n') { consumer.accept(outputStream.toString()); outputStream.reset(); } } catch (Exception e) { // log error and continue LOG.warn("Log message consumer crashed!", e); } } }); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy