/*
* Copyright 2015-present Facebook, Inc.
*
* Licensed 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 com.facebook.buck.apple.simulator;
import com.facebook.buck.log.Logger;
import com.facebook.buck.util.ProcessExecutor;
import com.facebook.buck.util.ProcessExecutorParams;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import java.io.IOException;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility class to manage starting the iOS simulator as well as installing and running applications
* inside it.
*/
public class AppleSimulatorController {
private static final Logger LOG = Logger.get(AppleSimulatorController.class);
private static final long SIMULATOR_POLL_TIMEOUT_MILLIS = 100;
private static final Pattern SIMCTL_LAUNCH_OUTPUT_PATTERN = Pattern.compile("^.*: ([0-9]+)$");
private final ProcessExecutor processExecutor;
private final Path simctlPath;
private final Path iosSimulatorPath;
public enum LaunchBehavior {
DO_NOT_WAIT_FOR_DEBUGGER,
WAIT_FOR_DEBUGGER;
}
public AppleSimulatorController(
ProcessExecutor processExecutor, Path simctlPath, Path iosSimulatorPath) {
this.processExecutor = processExecutor;
this.simctlPath = simctlPath;
this.iosSimulatorPath = iosSimulatorPath;
}
/**
* Starts up the iOS simulator, blocking the calling thread until the simulator boots or {@code
* timeoutMillis} passes, whichever happens first.
*
* <p>Call {@link #canStartSimulator(String)} before invoking this method to ensure the simulator
* can be started.
*
* @return The number of milliseconds waited if the simulator booted successfully, {@code
* Optional.empty()} otherwise.
*/
public Optional<Long> startSimulator(String simulatorUdid, long timeoutMillis)
throws IOException, InterruptedException {
if (!canStartSimulator(simulatorUdid)) {
LOG.warn("Cannot start simulator with UDID %s", simulatorUdid);
return Optional.empty();
}
// Even if the simulator is already running, we'll run this to bring it to the front.
if (!launchSimulatorWithUdid(iosSimulatorPath, simulatorUdid)) {
return Optional.empty();
}
Optional<Long> bootMillisWaited = waitForSimulatorToBoot(timeoutMillis, simulatorUdid);
if (!bootMillisWaited.isPresent()) {
LOG.warn("Simulator %s did not boot up within %d millis", simulatorUdid, timeoutMillis);
return Optional.empty();
}
return bootMillisWaited;
}
public boolean canStartSimulator(String simulatorUdid) throws IOException, InterruptedException {
ImmutableSet<String> bootedSimulatorDeviceUdids =
getBootedSimulatorDeviceUdids(processExecutor);
if (bootedSimulatorDeviceUdids.size() == 0) {
return true;
} else if (bootedSimulatorDeviceUdids.size() > 1) {
LOG.debug(
"Multiple simulators booted (%s), cannot start simulator.", bootedSimulatorDeviceUdids);
return false;
} else if (!bootedSimulatorDeviceUdids.contains(simulatorUdid)) {
LOG.debug(
"Booted simulator (%s) does not match desired (%s), cannot start simulator.",
Iterables.getOnlyElement(bootedSimulatorDeviceUdids), simulatorUdid);
return false;
} else {
return true;
}
}
private boolean launchSimulatorWithUdid(Path iosSimulatorPath, String simulatorUdid)
throws IOException, InterruptedException {
ImmutableList<String> command =
ImmutableList.of(
"open",
"-a",
iosSimulatorPath.toString(),
"--args",
"-CurrentDeviceUDID",
simulatorUdid);
LOG.debug("Launching iOS simulator %s: %s", simulatorUdid, command);
ProcessExecutorParams processExecutorParams =
ProcessExecutorParams.builder().setCommand(command).build();
ProcessExecutor.Result result = processExecutor.launchAndExecute(processExecutorParams);
if (result.getExitCode() != 0) {
LOG.error(result.getMessageForUnexpectedResult(command.toString()));
return false;
}
return true;
}
private ImmutableSet<String> getBootedSimulatorDeviceUdids(ProcessExecutor processExecutor)
throws IOException, InterruptedException {
ImmutableSet.Builder<String> bootedSimulatorUdids = ImmutableSet.builder();
for (AppleSimulator sim :
AppleSimulatorDiscovery.discoverAppleSimulators(processExecutor, simctlPath)) {
if (sim.getSimulatorState() == AppleSimulatorState.BOOTED) {
bootedSimulatorUdids.add(sim.getUdid());
}
}
return bootedSimulatorUdids.build();
}
/**
* Waits up to {@code timeoutMillis} for all simulators to shut down.
*
* @return The number of milliseconds waited if all simulators have shut down, {@code
* Optional.empty()} otherwise.
*/
public Optional<Long> waitForSimulatorsToShutdown(long timeoutMillis)
throws IOException, InterruptedException {
return waitForSimulatorState(
timeoutMillis,
"all simulators shutdown",
simulators -> {
for (AppleSimulator simulator : simulators) {
if (simulator.getSimulatorState() != AppleSimulatorState.SHUTDOWN) {
return false;
}
}
return true;
});
}
/**
* Waits up to {@code timeoutMillis} for the specified simulator to boot.
*
* @return The number of milliseconds waited if the specified simulator booted, {@code
* Optional.empty()} otherwise.
*/
public Optional<Long> waitForSimulatorToBoot(long timeoutMillis, final String simulatorUdid)
throws IOException, InterruptedException {
return waitForSimulatorState(
timeoutMillis,
String.format("simulator %s booted", simulatorUdid),
simulators -> {
for (AppleSimulator simulator : simulators) {
if (simulator.getUdid().equals(simulatorUdid)
&& simulator.getSimulatorState().equals(AppleSimulatorState.BOOTED)) {
return true;
}
}
return false;
});
}
private Optional<Long> waitForSimulatorState(
long timeoutMillis, String description, Predicate<ImmutableSet<AppleSimulator>> predicate)
throws IOException, InterruptedException {
boolean stateReached = false;
long millisWaited = 0;
while (!stateReached && millisWaited < timeoutMillis) {
LOG.debug("Checking if simulator state %s reached..", description);
if (predicate.apply(
AppleSimulatorDiscovery.discoverAppleSimulators(processExecutor, simctlPath))) {
LOG.debug("Simulator state %s reached.", description);
stateReached = true;
} else {
LOG.debug(
"Sleeping for %d ms waiting for simulator to reach state %s...",
SIMULATOR_POLL_TIMEOUT_MILLIS, description);
Thread.sleep(SIMULATOR_POLL_TIMEOUT_MILLIS);
millisWaited += SIMULATOR_POLL_TIMEOUT_MILLIS;
}
}
if (stateReached) {
return Optional.of(millisWaited);
} else {
LOG.debug("Simulator did not reach state %s within %d ms.", description, timeoutMillis);
return Optional.empty();
}
}
/**
* Installs a bundle in a previously-started simulator.
*
* @return true if the bundle was installed, false otherwise.
*/
public boolean installBundleInSimulator(String simulatorUdid, Path bundlePath)
throws IOException, InterruptedException {
ImmutableList<String> command =
ImmutableList.of(simctlPath.toString(), "install", simulatorUdid, bundlePath.toString());
ProcessExecutorParams processExecutorParams =
ProcessExecutorParams.builder().setCommand(command).build();
ProcessExecutor.Result result = processExecutor.launchAndExecute(processExecutorParams);
if (result.getExitCode() != 0) {
LOG.error(result.getMessageForUnexpectedResult(command.toString()));
return false;
}
return true;
}
/**
* Launches a previously-installed bundle in a started simulator.
*
* @return the process ID of the newly-launched process if successful, an absent value otherwise.
*/
public Optional<Long> launchInstalledBundleInSimulator(
String simulatorUdid, String bundleID, LaunchBehavior launchBehavior, List<String> args)
throws IOException, InterruptedException {
ImmutableList.Builder<String> commandBuilder = ImmutableList.builder();
commandBuilder.add(simctlPath.toString(), "launch");
if (launchBehavior == LaunchBehavior.WAIT_FOR_DEBUGGER) {
commandBuilder.add("-w");
}
commandBuilder.add(simulatorUdid, bundleID);
commandBuilder.addAll(args);
ImmutableList<String> command = commandBuilder.build();
ProcessExecutorParams processExecutorParams =
ProcessExecutorParams.builder().setCommand(command).build();
Set<ProcessExecutor.Option> options = EnumSet.of(ProcessExecutor.Option.EXPECTING_STD_OUT);
String message =
String.format(
"Launching bundle ID %s in simulator %s via command %s",
bundleID, simulatorUdid, command);
LOG.debug(message);
ProcessExecutor.Result result =
processExecutor.launchAndExecute(
processExecutorParams,
options,
/* stdin */ Optional.empty(),
/* timeOutMs */ Optional.empty(),
/* timeOutHandler */ Optional.empty());
if (result.getExitCode() != 0) {
LOG.error(result.getMessageForResult(message));
return Optional.empty();
}
Preconditions.checkState(result.getStdout().isPresent());
String trimmedStdout = result.getStdout().get().trim();
Matcher stdoutMatcher = SIMCTL_LAUNCH_OUTPUT_PATTERN.matcher(trimmedStdout);
if (!stdoutMatcher.find()) {
LOG.error("Could not parse output from %s: %s", command, trimmedStdout);
return Optional.empty();
}
return Optional.of(Long.parseLong(stdoutMatcher.group(1), 10));
}
}