Repository: beeware/briefcase

Issue:
Identify when XCode command line tools are missing, but XCode is installed
Hi,

Following the Hello World tutorial I got the error "Xcode is not installed." when trying to build the iOS version of the Hello World App. The problem turned out to be that Xcode does not by default install the command line tools and you have to go to Preferences -> Locations -> Command Line Tools and select the tools (in my case "Xcode 11.3.1").

Probably an idea to add this to the tutorial.

Keep up the good work,

 -- Howard.

Discussion:

Patch:
diff --git a/src/briefcase/__main__.py b/src/briefcase/__main__.py
index 48e5322e..1df57746 100644
--- a/src/briefcase/__main__.py
+++ b/src/briefcase/__main__.py
@@ -13,6 +13,11 @@ def main():
     except BriefcaseError as e:
         print(e, file=sys.stdout if e.error_code == 0 else sys.stderr)
         result = e.error_code
+    except KeyboardInterrupt:
+        print()
+        print("Aborted by user.")
+        print()
+        result = -42
 
     sys.exit(result)
 
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index d946d54b..301e2cf7 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -722,7 +722,7 @@ class CreateCommand(BaseCommand):
 
         Raises MissingToolException if a required system tool is missing.
         """
-        self.git = self.integrations.git.verify_git_is_installed()
+        self.git = self.integrations.git.verify_git_is_installed(self.host_os)
 
     def __call__(self, app: Optional[BaseConfig] = None, **kwargs):
         # Confirm all required tools are available
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 5bcf0a66..7896301c 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -508,7 +508,7 @@ Application '{formal_name}' has been generated. To run your application, type:
 
         Raises MissingToolException if a required system tool is missing.
         """
-        self.git = self.integrations.git.verify_git_is_installed()
+        self.git = self.integrations.git.verify_git_is_installed(self.host_os)
 
     def __call__(
         self,
diff --git a/src/briefcase/integrations/adb.py b/src/briefcase/integrations/adb.py
index 96227c0f..92a66010 100644
--- a/src/briefcase/integrations/adb.py
+++ b/src/briefcase/integrations/adb.py
@@ -146,8 +146,6 @@ If you do not see any devices, you can create and start an emulator by running:
 --name robotfriend --abi x86 \
 --package 'system-images;android-28;default;x86' --device pixel
 
-    $ echo 'disk.dataPartition.size=4096M' >> $HOME/.android/avd/robotfriend.avd/config.ini
-
     $ {emulator_path} -avd robotfriend &
 
 """.format(
diff --git a/src/briefcase/integrations/git.py b/src/briefcase/integrations/git.py
index 25b898af..e403b688 100644
--- a/src/briefcase/integrations/git.py
+++ b/src/briefcase/integrations/git.py
@@ -1,7 +1,7 @@
 from briefcase.exceptions import BriefcaseCommandError
 
 
-def verify_git_is_installed():
+def verify_git_is_installed(host_os=None):
     """
     Verify if git is installed.
 
@@ -23,11 +23,33 @@ def verify_git_is_installed():
         import git
         return git
     except ImportError:
-        raise BriefcaseCommandError("""
+        # macOS provides git as part of the Xcode command line tools,
+        # and also hijacks /usr/bin/git with a trigger that prompts the
+        # installation of those tools. Customize the message to account
+        # for this.
+        if host_os == 'Darwin':
+            raise BriefcaseCommandError("""
+Briefcase requires git, but it is not installed. Xcode provides git; you should
+be shown a dialog prompting you to install Xcode and the Command Line Developer
+Tools. Select "Install" to install the Command Line Developer Tools.
+
+Alternatively, you can visit:
+
+    https://git-scm.com/
+
+to download and install git manually.
+
+If you have installed git recently and are still getting this error, you may
+need to restart your terminal session.
+""")
+        else:
+            raise BriefcaseCommandError("""
 Briefcase requires git, but it is not installed (or is not on your PATH). Visit:
 
     https://git-scm.com/
 
-to download and install git. If you have installed git recently and are still
-getting this error, you may need to restart your terminal session.
+to download and install git manually.
+
+If you have installed git recently and are still getting this error, you may
+need to restart your terminal session.
 """)
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 0d1cb44e..f89fb3d2 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -2,6 +2,7 @@ import enum
 import json
 import re
 import subprocess
+from pathlib import Path
 
 from briefcase.exceptions import BriefcaseCommandError
 
@@ -13,20 +14,142 @@ class DeviceState(enum.Enum):
     UNKNOWN = 99
 
 
-def ensure_xcode_is_installed(min_version=None, sub=subprocess):
+def verify_command_line_tools_install(sub=subprocess):
+    """Verify that command line developer tools are installed and ready for use.
+
+    A completely clean machine will have neither Xcode *or* the Command Line
+    Tools. However, it's possible to install Xcode and *not* install the command
+    line tools, and vice versa.
+
+    Lastly, there is a license that needs to be accepted.
+
+    :param sub: the module for starting subprocesses. Defaults to
+        Python's builtin; used for testing purposes.
+    """
+    ensure_command_line_tools_are_installed(sub=sub)
+    confirm_xcode_license_accepted(sub=sub)
+
+
+def verify_xcode_install(min_version=None, sub=subprocess):
+    """Verify that Xcode and the command line developer tools are installed and
+    ready for use.
+
+    We need Xcode, *and* the Xcode Command Line Tools. A completely clean
+    machine will have neither Xcode *or* the Command Line Tools. However,
+    it's possible to install Xcode and *not* install the command line tools,
+    and vice versa.
+
+    We also need to ensure that an adequate version of Xcode is available.
+
+    Then, there is a license that needs to be accepted.
+
+    Lastly, we ensure that the iOS simulator is installed.
+
+    :param min_version: The minimum allowed version of Xcode, specified as a
+        tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
+        is no minimum version.
+    :param sub: the module for starting subprocesses. Defaults to
+        Python's builtin; used for testing purposes.
+    """
+    ensure_xcode_is_installed(min_version=min_version, sub=sub)
+    ensure_command_line_tools_are_installed(sub=sub)
+    confirm_xcode_license_accepted(sub=sub)
+
+
+def ensure_command_line_tools_are_installed(sub=subprocess):
+    """
+    Determine if the Xcode command line tools are installed.
+
+    If they are not installed, an exception is raised; in addition, a OS dialog
+    will be displayed prompting the user to install Xcode.
+
+    :param min_version: The minimum allowed version of Xcode, specified as a
+        tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
+        is no minimum version.
+    :param sub: the module for starting subprocesses. Defaults to
+        Python's builtin; used for testing purposes.
+    """
+    # We determine if the command line tools are installed by running:
+    #
+    #   xcode-select --install
+    #
+    # If that command exits with status 0, it means the tools are *not*
+    # installed; but a dialog will be displayed prompting an installation.
+    #
+    # If it returns a status code of 1, the tools are already installed
+    # and outputs the message "command line tools are already installed"
+    #
+    # Any other status code is a problem.
+    try:
+        sub.check_output(
+            ['xcode-select', '--install'],
+            stderr=subprocess.STDOUT
+        )
+        raise BriefcaseCommandError("""
+Xcode command line developer tools are not installed.
+
+You should be shown a dialog prompting you to install Xcode and the
+command line tools. Select "Install" to install the command line developer
+tools.
+
+Re-run Briefcase once that installation is complete.
+""")
+    except subprocess.CalledProcessError as e:
+        if e.returncode != 1:
+            print("""
+*************************************************************************
+** WARNING: Unable to determine if Xcode is installed                  **
+*************************************************************************
+
+   Briefcase will proceed assume everything is OK, but if you
+   experience problems, this is almost certainly the cause of those
+   problems.
+
+   Please report this as a bug at:
+
+     https://github.com/beeware/briefcase/issues/
+
+   In your report, please including the output from running:
+
+     xcode-select --install
+
+   from the command prompt.
+
+*************************************************************************
+""")
+
+
+def ensure_xcode_is_installed(
+    xcode_location='/Applications/Xcode.app',
+    min_version=None,
+    sub=subprocess
+):
     """
-    Determine if an appropriate version of Xcode is installed.
+    Determine if Xcode is installed; and if so, that it meets minimum version
+    requirements.
 
-    Raises an exception if Xcode is not installed, or if the version that is
-    installed doesn't meet the minimum requirement.
+    Raises an exception if XCode isn't installed, or if the version of Xcode
+    that is installed doesn't meet the minimum requirement.
 
+    :param xcode_location: The location where Xcode should be installed.
+        Default is `/Applications/Xcode.app`, which is where the App Store
+        installs Xcode.
     :param min_version: The minimum allowed version of Xcode, specified as a
         tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
         is no minimum version.
     :param sub: the module for starting subprocesses. Defaults to
         Python's builtin; used for testing purposes.
     """
+    # Try the direct approach. Look for the Xcode folder that is created
+    # when you install from the App store.
+    if not Path(xcode_location).exists():
+        raise BriefcaseCommandError("""
+Xcode is not installed.
+
+You can install Xcode from the macOS App Store.
 
+Re-run Briefcase once that installation is complete.
+""")
     try:
         output = sub.check_output(
             ['xcodebuild', '-version'],
@@ -53,7 +176,9 @@ def ensure_xcode_is_installed(min_version=None, sub=subprocess):
                                 version='.'.join(str(v) for v in version),
                             )
                         )
-                    return
+                    else:
+                        # Version number is acceptable
+                        return
                 except IndexError:
                     pass
 
@@ -84,11 +209,116 @@ def ensure_xcode_is_installed(min_version=None, sub=subprocess):
         raise BriefcaseCommandError("""
 Xcode is not installed.
 
+You should be shown a dialog prompting you to install Xcode and the
+command line tools. Select "Get Xcode" to install Xcode from the app store.
+
 You can install Xcode from the macOS App Store.
+
+Re-run Briefcase once that installation is complete.
+""")
+
+
+def confirm_xcode_license_accepted(sub=subprocess):
+    # Lastly, check if the XCode license has been accepted. The command line
+    # tools return a status code of 69 (nice...) if the license has not been
+    # accepted. In this case, we can prompt the user to accept the license.
+    try:
+        sub.check_output(
+            ['/usr/bin/clang', '--version'],
+            stderr=subprocess.STDOUT
+        )
+    except subprocess.CalledProcessError as e:
+        if e.returncode == 69:
+            print("""
+Use of Xcode and the iOS developer tools are covered by a license that must be
+accepted before you can use those tools.
+
+You can accept these licenses by starting Xcode and clicking "Accept"; or, you
+can run:
+
+    sudo xcodebuild -license
+
+at the command line and accepting the license there.
+
+Briefcase will try the command line version of this command now. You will need
+to enter your password (Briefcase will not store this password anywhere).
+""")
+            try:
+                sub.run(
+                    ['sudo', 'xcodebuild', '-license'],
+                    check=True,
+                )
+            except subprocess.CalledProcessError as e:
+                # status code 1 - sudo fail
+                # status code 69 - license not accepted.
+                if e.returncode == 1:
+                    raise BriefcaseCommandError("""
+Briefcase was unable to run the Xcode licensing tool. This may be because you
+did not enter your password correctly, or because your account does not have
+administrator priviliges on this computer.
+
+You need to accept the Xcode license before Briefcase can package your app.
+""")
+                elif e.returncode == 69:
+                    raise BriefcaseCommandError("""
+Xcode license has not been accepted. Briefcase cannot continue.
+
+You need to accept the Xcode license before Briefcase can package your app.
+""")
+                else:
+                    print("""
+*************************************************************************
+** WARNING: Unable to determine if the Xcode license has been accepted **
+*************************************************************************
+
+   Briefcase will proceed assume everything is OK, but if you
+   experience problems, this is almost certainly the cause of those
+   problems.
+
+   Please report this as a bug at:
+
+     https://github.com/beeware/briefcase/issues/
+
+   In your report, please including the output from running:
+
+     sudo xcodebuild -license
+
+   from the command prompt.
+
+*************************************************************************
+
+""")
+        else:
+            print("""
+*************************************************************************
+** WARNING: Unable to determine if the Xcode license has been accepted **
+*************************************************************************
+
+   Briefcase will proceed assume everything is OK, but if you
+   experience problems, this is almost certainly the cause of those
+   problems.
+
+   Please report this as a bug at:
+
+     https://github.com/beeware/briefcase/issues/
+
+   In your report, please including the output from running:
+
+     /usr/bin/clang --version
+
+   from the command prompt.
+
+*************************************************************************
+
 """)
 
 
-def get_simulators(os_name, sub=subprocess):
+def get_simulators(
+    os_name,
+    simulator_location='/Library/Developer/PrivateFrameworks/CoreSimulator.framework/',
+    sub=subprocess,
+    prompt=input,
+):
     """
     Obtain the simulators available on this machine.
 
@@ -98,10 +328,25 @@ def get_simulators(os_name, sub=subprocess):
 
     :param os_name: The OS that we want to simulate.
         One of `"iOS"`, `"watchOS"`, or `"tvOS"`.
+    :param simulator_location: The filesystem path where the simulator
+        frameworks are installed.
     :param sub: the module for starting subprocesses. Defaults to
         Python's builtin; used for testing purposes.
+    :param prompt: the method to use to prompt the user.
     :returns: A dictionary of available simulators.
     """
+    # If the simulator frameworks don't exist, they will be downloaded
+    # and installed. This should only occur on first execution.
+    if not Path(simulator_location).exists():
+        prompt("""
+It looks like the {os_name} Simulator is not installed. The {os_name} Simulator
+must be installed with administrator priviliges.
+
+xcodebuild will prompt you for your admin password so that it can download
+and install the simulator.
+
+Press Return to continue: """.format(os_name=os_name))
+
     try:
         simctl_data = json.loads(
             sub.check_output(
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 2af72b7e..2023f3a9 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -155,6 +155,7 @@ connection."""
         Verify that we the Android APK tools in `briefcase` will operate on
         this system, downloading tools as needed.
         """
+        super().verify_tools()
         self.verify_python_version()
         self.verify_sdk()
         self.verify_license()
diff --git a/src/briefcase/platforms/iOS/__init__.py b/src/briefcase/platforms/iOS/__init__.py
index c74542e1..be28c9de 100644
--- a/src/briefcase/platforms/iOS/__init__.py
+++ b/src/briefcase/platforms/iOS/__init__.py
@@ -1,5 +1,5 @@
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.xcode import ensure_xcode_is_installed
+from briefcase.integrations.xcode import verify_xcode_install
 
 DEFAULT_OUTPUT_FORMAT = 'xcode'
 
@@ -8,7 +8,6 @@ class iOSMixin:
     platform = 'iOS'
 
     def verify_tools(self):
-        super().verify_tools()
         if self.host_os != 'Darwin':
             raise BriefcaseCommandError("""
 iOS applications require Xcode, which is only available on macOS.
@@ -17,4 +16,8 @@ iOS applications require Xcode, which is only available on macOS.
         # Require XCode 10.0.0. There's no particular reason for this
         # specific version, other than it's a nice round number that's
         # not *that* old at time of writing.
-        ensure_xcode_is_installed(min_version=(10, 0, 0))
+        verify_xcode_install(min_version=(10, 0, 0))
+
+        # Verify superclass tools *after* xcode. This ensures we get the
+        # git check *after* the xcode check.
+        super().verify_tools()
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 5e2d24ff..8ab0662f 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -1,6 +1,22 @@
+from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.xcode import verify_command_line_tools_install
 
 DEFAULT_OUTPUT_FORMAT = 'dmg'
 
 
 class macOSMixin:
     platform = 'macOS'
+
+    def verify_tools(self):
+        if self.host_os != 'Darwin':
+            raise BriefcaseCommandError("""
+macOS applications require the Xcode command line tools, which are
+only available on macOS.
+""")
+
+        # Require the XCode command line tools.
+        verify_command_line_tools_install()
+
+        # Verify superclass tools *after* xcode. This ensures we get the
+        # git check *after* the xcode check.
+        super().verify_tools()


Test patch:
diff --git a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
new file mode 100644
index 00000000..9d2d24bd
--- /dev/null
+++ b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
@@ -0,0 +1,159 @@
+import subprocess
+from unittest import mock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.xcode import confirm_xcode_license_accepted
+
+
+def test_license_accepted(capsys):
+    "If the Xcode license has been accepted, pass without comment."
+    sub = mock.MagicMock()
+
+    # Check passes without an error...
+    confirm_xcode_license_accepted(sub=sub)
+
+    # ... clang was invoked ...
+    sub.check_output.assert_called_once_with(
+        ['/usr/bin/clang', '--version'],
+        stderr=subprocess.STDOUT,
+    )
+
+    # ... and the user is none the wiser
+    out = capsys.readouterr().out
+    assert len(out) == 0
+
+
+def test_unknown_error(capsys):
+    "If an unexpected problem occurred accepting the license, warn the user."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['/usr/bin/clang', '--version'],
+        returncode=1
+    )
+
+    # Check passes without an error...
+    confirm_xcode_license_accepted(sub=sub)
+
+    # ... clang was invoked ...
+    sub.check_output.assert_called_once_with(
+        ['/usr/bin/clang', '--version'],
+        stderr=subprocess.STDOUT,
+    )
+
+    # ...but stdout contains a warning
+    out = capsys.readouterr().out
+    assert '************' in out
+
+
+def test_accept_license():
+    "If the user accepts the license, continue without error"
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['/usr/bin/clang', '--version'],
+        returncode=69
+    )
+
+    # Check passes without an error...
+    confirm_xcode_license_accepted(sub=sub)
+
+    # ... clang *and* xcodebuild were invoked ...
+    sub.check_output.assert_called_once_with(
+        ['/usr/bin/clang', '--version'],
+        stderr=subprocess.STDOUT,
+    )
+    sub.run.assert_called_once_with(
+        ['sudo', 'xcodebuild', '-license'],
+        check=True,
+    )
+
+
+def test_sudo_fail():
+    "If the sudo call fails, an exception is raised."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['/usr/bin/clang', '--version'],
+        returncode=69
+    )
+    sub.run.side_effect = subprocess.CalledProcessError(
+        cmd=['sudo', 'xcodebuild', '-license'],
+        returncode=1
+    )
+
+    # Check raises an error:
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r'Briefcase was unable to run the Xcode licensing tool.'
+    ):
+        confirm_xcode_license_accepted(sub=sub)
+
+    # ... clang *and* xcodebuild were invoked ...
+    sub.check_output.assert_called_once_with(
+        ['/usr/bin/clang', '--version'],
+        stderr=subprocess.STDOUT,
+    )
+    sub.run.assert_called_once_with(
+        ['sudo', 'xcodebuild', '-license'],
+        check=True,
+    )
+
+
+def test_license_not_accepted():
+    "If the sudo call fails, an exception is raised."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['/usr/bin/clang', '--version'],
+        returncode=69
+    )
+    sub.run.side_effect = subprocess.CalledProcessError(
+        cmd=['sudo', 'xcodebuild', '-license'],
+        returncode=69
+    )
+
+    # Check raises an error:
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r'Xcode license has not been accepted.'
+    ):
+        confirm_xcode_license_accepted(sub=sub)
+
+    # ... clang *and* xcodebuild were invoked ...
+    sub.check_output.assert_called_once_with(
+        ['/usr/bin/clang', '--version'],
+        stderr=subprocess.STDOUT,
+    )
+    sub.run.assert_called_once_with(
+        ['sudo', 'xcodebuild', '-license'],
+        check=True,
+    )
+
+
+def test_license_status_unknown(capsys):
+    "If we get an unusual response from the license, warn but continue."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['/usr/bin/clang', '--version'],
+        returncode=69
+    )
+    sub.run.side_effect = subprocess.CalledProcessError(
+        cmd=['sudo', 'xcodebuild', '-license'],
+        returncode=42
+    )
+
+    # Check passes without error...
+    confirm_xcode_license_accepted(sub=sub)
+
+    # ... clang *and* xcodebuild were invoked ...
+    sub.check_output.assert_called_once_with(
+        ['/usr/bin/clang', '--version'],
+        stderr=subprocess.STDOUT,
+    )
+    sub.run.assert_called_once_with(
+        ['sudo', 'xcodebuild', '-license'],
+        check=True,
+    )
+
+    # ...but stdout contains a warning
+    out = capsys.readouterr().out
+    assert '************' in out
diff --git a/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py b/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
new file mode 100644
index 00000000..64b59c75
--- /dev/null
+++ b/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
@@ -0,0 +1,67 @@
+import subprocess
+from unittest import mock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.xcode import (
+    ensure_command_line_tools_are_installed
+)
+
+
+def test_not_installed():
+    "If cmdline dev tools are not installed, raise an error."
+    sub = mock.MagicMock()
+
+    with pytest.raises(BriefcaseCommandError):
+        ensure_command_line_tools_are_installed(sub=sub)
+
+    # xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcode-select', '--install'],
+        stderr=subprocess.STDOUT,
+    )
+
+
+def test_installed(capsys):
+    "If cmdline dev tools *are* installed, check passes without comment."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['xcode-select', '--install'],
+        returncode=1
+    )
+
+    # Check passes without an error...
+    ensure_command_line_tools_are_installed(sub=sub)
+
+    # ... xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcode-select', '--install'],
+        stderr=subprocess.STDOUT,
+    )
+
+    # ...and the user is none the wiser
+    out = capsys.readouterr().out
+    assert len(out) == 0
+
+
+def test_unsure_if_installed(capsys):
+    "If xcode-select returns something odd, mention it but don't break."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['xcode-select', '--install'],
+        returncode=69
+    )
+
+    # Check passes without an error...
+    ensure_command_line_tools_are_installed(sub=sub)
+
+    # ... xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcode-select', '--install'],
+        stderr=subprocess.STDOUT,
+    )
+
+    # ...but stdout contains a warning
+    out = capsys.readouterr().out
+    assert '************' in out
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index feed33b6..ad782bda 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -7,7 +7,15 @@ from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.xcode import ensure_xcode_is_installed
 
 
-def test_not_installed():
+@pytest.fixture
+def xcode(tmp_path):
+    "Create a dummy location for Xcode"
+    xcode_location = tmp_path / 'Xcode.app'
+    xcode_location.mkdir(parents=True, exist_ok=True)
+    return str(xcode_location)
+
+
+def test_not_installed(tmp_path):
     "If Xcode is not installed, raise an error."
     sub = mock.MagicMock()
     sub.check_output.side_effect = subprocess.CalledProcessError(
@@ -15,17 +23,48 @@ def test_not_installed():
         returncode=1
     )
 
+    # Test a location where Xcode *won't* be installed
     with pytest.raises(BriefcaseCommandError):
-        ensure_xcode_is_installed(sub=sub)
+        ensure_xcode_is_installed(
+            xcode_location=str(tmp_path / 'Xcode.app'),
+            sub=sub
+        )
+
+    # xcode-select was not invoked
+    sub.check_output.assert_not_called()
+
+
+def test_exists_but_not_installed(xcode):
+    "If the Xcode folder exists, but xcodebuild breaks, raise an error."
+    sub = mock.MagicMock()
+    sub.check_output.side_effect = subprocess.CalledProcessError(
+        cmd=['xcodebuild', '-version'],
+        returncode=1
+    )
 
+    with pytest.raises(BriefcaseCommandError):
+        ensure_xcode_is_installed(xcode_location=xcode, sub=sub)
 
-def test_installed_no_minimum_version():
+    # xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcodebuild', '-version'],
+        universal_newlines=True,
+    )
+
+
+def test_installed_no_minimum_version(xcode):
     "If Xcode is installed, but there's no minimum version, check is satisfied."
     sub = mock.MagicMock()
     sub.check_output.return_value = "Xcode 11.2.1\nBuild version 11B500\n"
 
     # Check passes without an error.
-    ensure_xcode_is_installed(sub=sub)
+    ensure_xcode_is_installed(xcode_location=xcode, sub=sub)
+
+    # xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcodebuild', '-version'],
+        universal_newlines=True,
+    )
 
 
 @pytest.mark.parametrize(
@@ -66,7 +105,7 @@ def test_installed_no_minimum_version():
         ((11, ), '11.2'),  # Exact match, implied minor version.
     ]
 )
-def test_installed_with_minimum_version_success(min_version, version, capsys):
+def test_installed_with_minimum_version_success(min_version, version, capsys, xcode):
     "Check XCode can meet a minimum version requirement."
     sub = mock.MagicMock()
     sub.check_output.return_value = "Xcode {version}\nBuild version 11B500\n".format(
@@ -74,7 +113,17 @@ def test_installed_with_minimum_version_success(min_version, version, capsys):
     )
 
     # Check passes without an error.
-    ensure_xcode_is_installed(min_version=min_version, sub=sub)
+    ensure_xcode_is_installed(
+        min_version=min_version,
+        xcode_location=xcode,
+        sub=sub
+    )
+
+    # xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcodebuild', '-version'],
+        universal_newlines=True,
+    )
 
     # Make sure the warning wasn't displayed.
     out = capsys.readouterr().out
@@ -93,7 +142,7 @@ def test_installed_with_minimum_version_success(min_version, version, capsys):
         ((9, ), '8.2.1'),  # Insufficient major version
     ]
 )
-def test_installed_with_minimum_version_failure(min_version, version):
+def test_installed_with_minimum_version_failure(min_version, version, xcode):
     "Check XCode fail to meet a minimum version requirement."
     sub = mock.MagicMock()
     sub.check_output.return_value = "Xcode {version}\nBuild version 11B500\n".format(
@@ -102,16 +151,36 @@ def test_installed_with_minimum_version_failure(min_version, version):
 
     # Check raises an error.
     with pytest.raises(BriefcaseCommandError):
-        ensure_xcode_is_installed(min_version=min_version, sub=sub)
+        ensure_xcode_is_installed(
+            min_version=min_version,
+            xcode_location=xcode,
+            sub=sub
+        )
+
+    # xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcodebuild', '-version'],
+        universal_newlines=True,
+    )
 
 
-def test_unexpected_version_output(capsys):
+def test_unexpected_version_output(capsys, xcode):
     "If xcodebuild returns unexpected output, assume it's ok..."
     sub = mock.MagicMock()
     sub.check_output.return_value = "Wibble Wibble Wibble\n"
 
     # Check passes without an error...
-    ensure_xcode_is_installed(min_version=(11, 2, 1), sub=sub)
+    ensure_xcode_is_installed(
+        min_version=(11, 2, 1),
+        xcode_location=xcode,
+        sub=sub
+    )
+
+    # xcode-select was invoked
+    sub.check_output.assert_called_once_with(
+        ['xcodebuild', '-version'],
+        universal_newlines=True,
+    )
 
     # ...but stdout contains a warning
     out = capsys.readouterr().out
diff --git a/tests/integrations/xcode/test_get_simulators.py b/tests/integrations/xcode/test_get_simulators.py
index b294960a..2f2d9854 100644
--- a/tests/integrations/xcode/test_get_simulators.py
+++ b/tests/integrations/xcode/test_get_simulators.py
@@ -8,6 +8,14 @@ from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.xcode import get_simulators
 
 
+@pytest.fixture
+def simulator(tmp_path):
+    "Create a dummy location for the simulator"
+    simulator_location = tmp_path / 'CoreSimulator.framework'
+    simulator_location.mkdir(parents=True, exist_ok=True)
+    return str(simulator_location)
+
+
 def simctl_result(name):
     """Load a simctl result file from the sample directory, and return the content"""
     filename = Path(__file__).parent / 'simctl' / '{name}.json'.format(name=name)
@@ -15,7 +23,28 @@ def simctl_result(name):
         return f.read()
 
 
-def test_simctl_missing():
+def test_simulator_is_missing(tmp_path):
+    "If the simulator is not installed, a prompt is shown to the user"
+    mock_input = mock.MagicMock()
+
+    sub = mock.MagicMock()
+    sub.check_output.return_value = simctl_result('no-runtimes')
+
+    simulators = get_simulators(
+        'iOS',
+        simulator_location=str(tmp_path / 'CoreSimulator.framework'),
+        sub=sub,
+        prompt=mock_input,
+    )
+
+    # The prompt was displayed
+    assert mock_input.call_count == 1
+
+    # The call returned without error.
+    assert simulators == {}
+
+
+def test_simctl_missing(simulator):
     "If simctl is missing or fails to start, an exception is raised."
     sub = mock.MagicMock()
     sub.check_output.side_effect = subprocess.CalledProcessError(
@@ -24,25 +53,42 @@ def test_simctl_missing():
     )
 
     with pytest.raises(BriefcaseCommandError):
-        get_simulators('iOS', sub=sub)
+        get_simulators('iOS', simulator_location=simulator, sub=sub)
 
 
-def test_no_runtimes():
+def test_no_runtimes(simulator):
     "If there are no runtimes available, no simulators will be found"
+    mock_input = mock.MagicMock()
+
     sub = mock.MagicMock()
     sub.check_output.return_value = simctl_result('no-runtimes')
 
-    simulators = get_simulators('iOS', sub=sub)
+    simulators = get_simulators(
+        'iOS',
+        simulator_location=simulator,
+        sub=sub,
+        prompt=mock_input
+    )
 
     assert simulators == {}
 
+    # The prompt was not shown
+    mock_input.assert_not_called()
+
 
-def test_single_iOS_runtime():
+def test_single_iOS_runtime(simulator):
     "If an iOS version is installed, devices can be found"
+    mock_input = mock.MagicMock()
+
     sub = mock.MagicMock()
     sub.check_output.return_value = simctl_result('iOS-13.2-only')
 
-    simulators = get_simulators('iOS', sub=sub)
+    simulators = get_simulators(
+        'iOS',
+        simulator_location=simulator,
+        sub=sub,
+        prompt=mock_input
+    )
 
     assert simulators == {
         'iOS 13.2': {
@@ -59,13 +105,23 @@ def test_single_iOS_runtime():
         }
     }
 
+    # The prompt was not shown
+    mock_input.assert_not_called()
 
-def test_watchOS_runtime():
+
+def test_watchOS_runtime(simulator):
     "Runtimes other than iOS can be requested."
+    mock_input = mock.MagicMock()
+
     sub = mock.MagicMock()
     sub.check_output.return_value = simctl_result('iOS-13.2-only')
 
-    simulators = get_simulators('watchOS', sub=sub)
+    simulators = get_simulators(
+        'watchOS',
+        simulator_location=simulator,
+        sub=sub,
+        prompt=mock_input
+    )
 
     assert simulators == {
         'watchOS 6.1': {
@@ -76,13 +132,23 @@ def test_watchOS_runtime():
         }
     }
 
+    # The prompt was not shown
+    mock_input.assert_not_called()
+
 
-def test_multiple_iOS_runtime():
+def test_multiple_iOS_runtime(simulator):
     "If multiple iOS versions are installed, this will be reflected in results"
+    mock_input = mock.MagicMock()
+
     sub = mock.MagicMock()
     sub.check_output.return_value = simctl_result('multiple-iOS-versions')
 
-    simulators = get_simulators('iOS', sub=sub)
+    simulators = get_simulators(
+        'iOS',
+        simulator_location=simulator,
+        sub=sub,
+        prompt=mock_input
+    )
 
     assert simulators == {
         'iOS 13.2': {
@@ -117,23 +183,43 @@ def test_multiple_iOS_runtime():
         }
     }
 
+    # The prompt was not shown
+    mock_input.assert_not_called()
 
-def test_unknown_runtime():
+
+def test_unknown_runtime(simulator):
     "If an unknown runtime is requested, no devices will be found"
+    mock_input = mock.MagicMock()
+
     sub = mock.MagicMock()
     sub.check_output.return_value = simctl_result('multiple-iOS-versions')
 
-    simulators = get_simulators('whizzOS', sub=sub)
+    simulators = get_simulators(
+        'whizzOS',
+        simulator_location=simulator,
+        sub=sub,
+        prompt=mock_input
+    )
 
     assert simulators == {}
 
+    # The prompt was not shown
+    mock_input.assert_not_called()
+
 
-def test_alternate_format():
+def test_alternate_format(simulator):
     "The alternate format for device versions can be parsed"
+    mock_input = mock.MagicMock()
+
     sub = mock.MagicMock()
     sub.check_output.return_value = simctl_result('alternate-format')
 
-    simulators = get_simulators('iOS', sub=sub)
+    simulators = get_simulators(
+        'iOS',
+        simulator_location=simulator,
+        sub=sub,
+        prompt=mock_input
+    )
 
     assert simulators == {
         'iOS 12.0': {
@@ -186,3 +272,6 @@ def test_alternate_format():
             'F7EF0E11-864C-42A2-8D80-4DBE78AFD86B': 'iPhone 6 Plus'
         }
     }
+
+    # The prompt was not shown
+    mock_input.assert_not_called()