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 static com.google.common.base.Preconditions.checkNotNull;
import static io.appium.java_client.service.local.AppiumServiceBuilder.BROADCAST_IP_ADDRESS;
import static org.slf4j.event.Level.DEBUG;
import static org.slf4j.event.Level.INFO;

import com.google.common.annotations.VisibleForTesting;

import lombok.SneakyThrows;
import org.apache.commons.lang3.StringUtils;

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 java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.time.Duration;
import java.util.List;
import java.util.Map;
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 javax.annotation.Nullable;

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 LOG_MESSAGE_PATTERN = Pattern.compile("^(.*)\\R");
    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 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().replace(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) {
                return false;
            }

            if (!process.isRunning()) {
                return false;
            }

            try {
                ping(Duration.ofMillis(1500));
                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 {
        // The operating system might block direct access to the universal broadcast IP address
        URL status = addSuffix(replaceHost(getUrl(), BROADCAST_IP_ADDRESS, "127.0.0.1"), "/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()
     */
    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 (Throwable e) {
                destroyProcess();
                String msgTxt = "The local appium server has not been started. "
                        + "The given Node.js executable: " + this.nodeJSExec.getAbsolutePath()
                        + " Arguments: " + nodeJSArgs.toString() + " " + "\n";
                if (process != null) {
                    String processStream = process.getStdOut();
                    if (!StringUtils.isBlank(processStream)) {
                        msgTxt = msgTxt + "Process output: " + processStream + "\n";
                    }
                }

                throw new AppiumServerHasNotBeenStartedLocallyException(msgTxt, 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.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 {
            Field processField = process.getClass().getDeclaredField("process");
            processField.setAccessible(true);
            Object osProcess = processField.get(process);
            Field watchdogField = osProcess.getClass().getDeclaredField("executeWatchdog");
            watchdogField.setAccessible(true);
            Object watchdog = watchdogField.get(osProcess);
            Field nativeProcessField = watchdog.getClass().getDeclaredField("process");
            nativeProcessField.setAccessible(true);
            Process nativeProcess = (Process) nativeProcessField.get(watchdog);
            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 stream : outputStreams) {
            addOutPutStream(stream);
        }
    }

    /**
     * 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() { StringBuilder lineBuilder = new StringBuilder(); @Override public void write(int chr) { try { lineBuilder.append((char) chr); Matcher matcher = LOG_MESSAGE_PATTERN.matcher(lineBuilder.toString()); if (matcher.matches()) { consumer.accept(matcher.group(1)); lineBuilder = new StringBuilder(); } } catch (Exception e) { // log error and continue LOG.warn("Log message consumer crashed!", e); } } }); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy