Skip to content

Commit

Permalink
feat: Prompt to select what to nuke and iOS simulator (#311)
Browse files Browse the repository at this point in the history
* feat: nuke will prompt for selections if no flags provided

* feat: app ios commands prompt for device if not provided

* chore: Improve error handling around SIGINT
  • Loading branch information
cszatmary authored Feb 15, 2022
1 parent 0d3f2d2 commit 96652d2
Show file tree
Hide file tree
Showing 16 changed files with 550 additions and 271 deletions.
16 changes: 0 additions & 16 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"io"
"os"
"strings"
"sync"

"github.com/TouchBistro/goutils/log"
Expand All @@ -16,21 +15,6 @@ import (
"github.com/spf13/cobra"
)

// Prompt prompts the user for the answer to a yes/no question.
func Prompt(msg string) bool {
// check for yes and assume no on any other input to avoid annoyance
fmt.Print(msg)
var resp string
_, err := fmt.Scanln(&resp)
if err != nil {
return false
}
if strings.ToLower(string(resp[0])) == "y" {
return true
}
return false
}

// ExpectSingleArg returns a function that validates the command only receives a single arg.
// name is the name of the arg and is used in the error message.
func ExpectSingleArg(name string) cobra.PositionalArgs {
Expand Down
35 changes: 35 additions & 0 deletions cli/commands/app/ios/ios.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package ios

import (
"github.com/AlecAivazis/survey/v2"
"github.com/TouchBistro/goutils/fatal"
"github.com/TouchBistro/tb/cli"
"github.com/TouchBistro/tb/engine"
"github.com/spf13/cobra"
)

Expand All @@ -14,3 +17,35 @@ func NewiOSCommand(c *cli.Container) *cobra.Command {
iosCmd.AddCommand(newLogsCommand(c), newRunCommand(c))
return iosCmd
}

func resolveDeviceName(c *cli.Container, appName, iosVersion, deviceName string) (resolvediOSVersion, resolvedDeviceName string, err error) {
if deviceName != "" {
// deviceName was provided so use that, even if iosVersion is empty it will be resolved later.
return iosVersion, deviceName, nil
}
// Prompt the user to select a device
var deviceNames []string
deviceNames, resolvediOSVersion, err = c.Engine.AppiOSListDevices(c.Ctx, engine.AppiOSListDevicesOptions{
AppName: appName,
IOSVersion: iosVersion,
})
if err != nil {
return "", "", &fatal.Error{
Msg: "Failed to get list of iOS devices",
Err: err,
}
}

prompt := &survey.Select{
Message: "Select iOS simulator device to use:",
Options: deviceNames,
}
var selected string
if err := survey.AskOne(prompt, &selected); err != nil {
return "", "", &fatal.Error{
Msg: "Failed to prompt for iOS device",
Err: err,
}
}
return resolvediOSVersion, selected, nil
}
15 changes: 11 additions & 4 deletions cli/commands/app/ios/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,21 @@ Displays the last 10 logs in the default iOS simulator:
Displays the last 20 logs in an iOS 12.4 iPad Air 2 simulator:
tb app logs --number 20 --ios-version 12.4 --device iPad Air 2`,
tb app logs --number 20 --ios-version 12.4 --device "iPad Air 2"`,
RunE: func(cmd *cobra.Command, args []string) error {
iosVersion, deviceName, err := resolveDeviceName(c, "", opts.iosVersion, opts.deviceName)
if err != nil {
return err
}

logsPath, err := c.Engine.AppiOSLogsPath(c.Ctx, engine.AppiOSLogsPathOptions{
IOSVersion: opts.iosVersion,
DeviceName: opts.deviceName,
IOSVersion: iosVersion,
DeviceName: deviceName,
})
if err != nil {
return err
}

c.Tracker.Info("Attaching to simulator logs")
tail := exec.CommandContext(c.Ctx, "tail", "-f", "-n", opts.numberOfLines, logsPath)
tail.Stdout = os.Stdout
Expand All @@ -51,7 +57,8 @@ Displays the last 20 logs in an iOS 12.4 iPad Air 2 simulator:
if errors.As(err, &exitErr) {
return &fatal.Error{Code: exitErr.ExitCode()}
}
return &fatal.Error{Err: err}
// Error isn't from tail, some other error occurred while trying to run it.
return &fatal.Error{Msg: "Failed to view simulator logs", Err: err}
}
return nil
},
Expand Down
13 changes: 9 additions & 4 deletions cli/commands/app/ios/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ Run the current master build of TouchBistro in the default iOS Simulator:
Run the build for specific branch in an iOS 12.3 iPad Air 2 simulator:
tb app ios run TouchBistro --ios-version 12.3 --device iPad Air 2 --branch task/pay-631/fix-thing`,
tb app ios run TouchBistro --ios-version 12.3 --device "iPad Air 2" --branch task/pay-631/fix-thing`,
RunE: func(cmd *cobra.Command, args []string) error {
appName := args[0]
err := c.Engine.AppiOSRun(c.Ctx, appName, engine.AppiOSRunOptions{
IOSVersion: opts.iosVersion,
DeviceName: opts.deviceName,
iosVersion, deviceName, err := resolveDeviceName(c, appName, opts.iosVersion, opts.deviceName)
if err != nil {
return err
}

err = c.Engine.AppiOSRun(c.Ctx, appName, engine.AppiOSRunOptions{
IOSVersion: iosVersion,
DeviceName: deviceName,
DataPath: opts.dataPath,
Branch: opts.branch,
})
Expand Down
62 changes: 52 additions & 10 deletions cli/commands/nuke.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"os"

"github.com/AlecAivazis/survey/v2"
"github.com/TouchBistro/goutils/fatal"
"github.com/TouchBistro/tb/cli"
"github.com/TouchBistro/tb/engine"
Expand Down Expand Up @@ -31,18 +32,25 @@ func newNukeCommand(c *cli.Container) *cobra.Command {
docker containers, docker images, docker networks, docker volumes, cloned service git repos,
downloaded iOS apps, downloaded desktop apps, cloned registries.
Flags must be provided to specify which resources to remove. The special --all flag causes all
resources to be removed, and also removes the directory where tb stores data.
By default, nuke will prompt the user to select which resources to remove.
Flags may be provided to bypass the prompt and specify which resources to remove.
The special --all flag causes all resources to be removed, and also removes the
directory where tb stores data.
If any docker resources are specified to be removed, any running service containers will first be stopped.
If any docker resources are specified to be removed, any running service containers will
first be stopped and all service containers will be removed.
tb nuke will not remove any docker resources that are not managed by tb.
Examples:
Remove all docker containers and images:
Prompt to select resources to remove:
tb nuke --containers --images
tb nuke
Remove all docker images and volumes (will also remove docker containers):
tb nuke --images --volumes
Remove all downloaded iOS and desktop apps:
Expand All @@ -52,11 +60,45 @@ Remove everything (completely wipe all tb data):
tb nuke --all`,
RunE: func(cmd *cobra.Command, args []string) error {
// If no flags were provided do an interactive prompt and ask the user
// what they would like to remove.
if !opts.nukeContainers && !opts.nukeImages && !opts.nukeVolumes &&
!opts.nukeNetworks && !opts.nukeRepos && !opts.nukeDesktopApps &&
!opts.nukeIOSBuilds && !opts.nukeRegistries && !opts.nukeAll {
return &fatal.Error{
Msg: "Error: Must specify what to nuke. Try tb nuke --help to see all the options.",
choices := []struct {
name string
optionField *bool
}{
{"Containers", &opts.nukeContainers},
{"Images", &opts.nukeImages},
{"Volumes", &opts.nukeVolumes},
{"Networks", &opts.nukeNetworks},
{"Repos", &opts.nukeRepos},
{"Desktop Apps", &opts.nukeDesktopApps},
{"iOS Apps", &opts.nukeIOSBuilds},
{"Registries", &opts.nukeRegistries},
}
var promptOptions []string
for _, c := range choices {
promptOptions = append(promptOptions, c.name)
}

prompt := &survey.MultiSelect{
Message: "Choose what to remove:",
Options: promptOptions,
PageSize: len(promptOptions), // Make sure all choices are rendered without pagination
}
var selected []int
// Use required validator to enforce that at least one option is selected.
err := survey.AskOne(prompt, &selected, survey.WithValidator(survey.Required))
if err != nil {
return &fatal.Error{
Msg: "Failed to prompt for what to remove",
Err: err,
}
}
for _, si := range selected {
*choices[si].optionField = true
}
}
err := c.Engine.Nuke(c.Ctx, engine.NukeOptions{
Expand Down Expand Up @@ -92,9 +134,9 @@ Remove everything (completely wipe all tb data):

flags := nukeCmd.Flags()
flags.BoolVar(&opts.nukeContainers, "containers", false, "Remove all service containers")
flags.BoolVar(&opts.nukeImages, "images", false, "Remove all images")
flags.BoolVar(&opts.nukeVolumes, "volumes", false, "Remove all volumes")
flags.BoolVar(&opts.nukeNetworks, "networks", false, "Remove all networks")
flags.BoolVar(&opts.nukeImages, "images", false, "Remove all images (implies --containers)")
flags.BoolVar(&opts.nukeVolumes, "volumes", false, "Remove all volumes (implies --containers)")
flags.BoolVar(&opts.nukeNetworks, "networks", false, "Remove all networks (implies --containers)")
flags.BoolVar(&opts.nukeRepos, "repos", false, "Remove all service git repos")
flags.BoolVar(&opts.nukeDesktopApps, "desktop", false, "Remove all downloaded desktop app builds")
flags.BoolVar(&opts.nukeIOSBuilds, "ios", false, "Remove all downloaded iOS app builds")
Expand Down
101 changes: 71 additions & 30 deletions engine/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,51 @@ import (
"github.com/TouchBistro/tb/resource/app"
)

// AppiOSListDevicesOptions customizes the behavior of AppiOSListDevices.
type AppiOSListDevicesOptions struct {
// AppName is the name of an iOS app.
// If provided, only devices that this app can run on will be returned.
AppName string
// IOSVersion is the iOS version to use.
// If omitted, the latest available iOS version will be used.
IOSVersion string
}

// AppiOSListDevices returns a list of device names for available devices based on opts.
// It also returns the resolved iOS version.
func (e *Engine) AppiOSListDevices(ctx context.Context, opts AppiOSListDevicesOptions) ([]string, string, error) {
const op = errors.Op("engine.Engine.AppiOSListDevices")
iosVersion, err := e.resolveiOSVersion(ctx, opts.IOSVersion, op)
if err != nil {
return nil, "", err
}

deviceType := simulator.DeviceTypeUnspecified
if opts.AppName != "" {
a, err := e.iosApps.Get(opts.AppName)
if err != nil {
return nil, "", errors.Wrap(err, errors.Meta{Reason: "unable to resolve iOS app", Op: op})
}
deviceType = a.DeviceType()
}

devices, err := e.deviceList.ListDevices(iosVersion, deviceType)
if err != nil {
return nil, "", errors.Wrap(err, errors.Meta{Reason: "unable to get available iOS devices", Op: op})
}
deviceNames := make([]string, len(devices))
for i, d := range devices {
deviceNames[i] = d.Name
}
return deviceNames, iosVersion, nil
}

// AppiOSRunOptions customizes the behaviour of AppiOSRun.
// All fields are optional.
type AppiOSRunOptions struct {
// IOSVersion is the iOS version to use.
IOSVersion string
// DeviceName is the name of the device to use.
// This field is required.
DeviceName string
// DataPath is the path to a data directory to inject into the simulator.
DataPath string
Expand All @@ -42,7 +81,7 @@ func (e *Engine) AppiOSRun(ctx context.Context, appName string, opts AppiOSRunOp
if opts.Branch != "" {
a.Branch = opts.Branch
}
device, err := e.resolveDevice(ctx, opts.IOSVersion, opts.DeviceName, a.DeviceType(), op)
device, err := e.resolveDevice(ctx, opts.IOSVersion, opts.DeviceName, op)
if err != nil {
return err
}
Expand Down Expand Up @@ -120,8 +159,13 @@ func (e *Engine) AppiOSRun(ctx context.Context, appName string, opts AppiOSRunOp
tracker.UpdateMessage("Setting environment variables")
for k, v := range a.EnvVars {
tracker.Debugf("Setting %s to %s", k, v)
// Env vars can be passed to simctl if they are set in the calling environment with a SIMCTL_CHILD_ prefix.
os.Setenv(fmt.Sprintf("SIMCTL_CHILD_%s", k), v)
if err := sim.Setenv(k, v); err != nil {
return errors.Wrap(err, errors.Meta{
Kind: errkind.Internal,
Reason: fmt.Sprintf("failed to set env var %s in simulator", k),
Op: op,
})
}
}
tracker.UpdateMessage("Launching app in simulator")
if err := sim.LaunchApp(ctx, a.BundleID); err != nil {
Expand All @@ -135,53 +179,50 @@ func (e *Engine) AppiOSRun(ctx context.Context, appName string, opts AppiOSRunOp
}

// AppiOSLogsPathOptions customizes the behaviour of AppiOSLogsPath.
// All fields are optional.
type AppiOSLogsPathOptions struct {
// IOSVersion is the iOS version to use.
IOSVersion string
// DeviceName is the name of the device to use.
// This field is required.
DeviceName string
}

// AppiOSLogsPath returns the path where logs are stored for the given simulator.
func (e *Engine) AppiOSLogsPath(ctx context.Context, opts AppiOSLogsPathOptions) (string, error) {
const op = errors.Op("engine.Engine.AppiOSRun")
tracker := progress.TrackerFromContext(ctx)
device, err := e.resolveDevice(ctx, opts.IOSVersion, opts.DeviceName, simulator.DeviceTypeUnspecified, op)
device, err := e.resolveDevice(ctx, opts.IOSVersion, opts.DeviceName, op)
if err != nil {
return "", err
}
tracker.Debugf("Found device UDID: %s", device.UDID)
homedir, err := os.UserHomeDir()
return filepath.Join(device.LogPath, "system.log"), nil
}

// resolveiOSVersion finds the latest iOS version and returns it if iosVersion is empty.
// If iosVersion is not empty, it is returned as is.
func (e *Engine) resolveiOSVersion(ctx context.Context, iosVersion string, op errors.Op) (string, error) {
if iosVersion != "" {
return iosVersion, nil
}
latestVersion, err := e.deviceList.GetLatestiOSVersion()
if err != nil {
return "", errors.Wrap(err, errors.Meta{
Kind: errkind.Internal,
Reason: "unable to find user home directory",
Op: op,
})
return "", errors.Wrap(err, errors.Meta{Reason: "unable to get latest iOS version", Op: op})
}
return filepath.Join(homedir, "Library/Logs/CoreSimulator", device.UDID, "system.log"), nil
tracker := progress.TrackerFromContext(ctx)
tracker.Infof("No iOS version provided, defaulting to version %s", latestVersion)
return latestVersion, nil
}

func (e *Engine) resolveDevice(ctx context.Context, iosVersion, deviceName string, deviceType simulator.DeviceType, op errors.Op) (simulator.Device, error) {
tracker := progress.TrackerFromContext(ctx)
// Figure out default iOS version if it wasn't provided
if iosVersion == "" {
var err error
iosVersion, err = e.deviceList.GetLatestiOSVersion()
if err != nil {
return simulator.Device{}, errors.Wrap(err, errors.Meta{Reason: "unable to get latest iOS version", Op: op})
}
tracker.Infof("No iOS version provided, defaulting to version %s", iosVersion)
func (e *Engine) resolveDevice(ctx context.Context, iosVersion, deviceName string, op errors.Op) (simulator.Device, error) {
var err error
iosVersion, err = e.resolveiOSVersion(ctx, iosVersion, op)
if err != nil {
return simulator.Device{}, err
}
// Make sure a device was provided as it is required
if deviceName == "" {
// Figure out default iOS device if it wasn't provided
device, err := e.deviceList.GetDefaultDevice(iosVersion, deviceType)
if err != nil {
return device, errors.Wrap(err, errors.Meta{Reason: "failed to get default iOS simulator", Op: op})
}
tracker.Infof("No iOS simulator provided, defaulting to %s\n", device.Name)
return device, nil
return simulator.Device{}, errors.New(errkind.Invalid, "no iOS device provided", op)
}
// Find specified device
device, err := e.deviceList.GetDevice(iosVersion, deviceName)
Expand Down
Loading

0 comments on commit 96652d2

Please sign in to comment.