Repository: beeware/briefcase

Issue:
Misleading error message if only Xcode command line tools are installed.

If you only have the Xcode command line tools installed, the error message returned is misleading.

**To Reproduce**
Steps to reproduce the behavior:
1. Install the Xcode command line tools *only* (i.e., `/Applications/Xcode.app` must *not* exist)
2. Run `briefcase create iOS`
3. See error

```
Xcode may be installed, but the active developer directory is a
command line tools instance. To make the default Xcode install the
active developer directory, run:

    $ sudo xcode-select --switch /Applications/Xcode.app

Or, to use a version of Xcode installed in a non-default location:

    $ sudo xcode-select --switch /path/to/Xcode.app

and then re-run Briefcase.
```

This isn't *wrong* - it's just misleading because it suggests Xcode *may* be installed, even though we could easily identify that it isn't.

**Expected behavior**

The error should not imply Xcode might be installed when we can identify that it isn't. We should be able to identify and differentiate the following cases:

1. Neither Xcode nor the cmdline tools are installed.
2. Xcode is installed, but the cmdline tools are *not* installed.
3. Xcode is installed in a non-default location, but the cmdline tools are *not* installed.
4. The Cmdline tools are installed but Xcode is *not* installed.
5. Cmdline tools and Xcode are installed.
6. Cmdline tools and Xcode are installed; Xcode is in a non-default location.

**Environment:**
 - Operating System: macOS
 - Software versions:
   - Briefcase: 0.3.6



Discussion:

Patch:
diff --git a/changes/763.bugfix.rst b/changes/763.bugfix.rst
new file mode 100644
index 00000000..0d8d8b6a
--- /dev/null
+++ b/changes/763.bugfix.rst
@@ -0,0 +1,1 @@
+Clarified the error message when Xcode is not installed, but the Xcode Commandline Tools are installed.
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index 7e352952..2fecf6cf 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -117,8 +117,8 @@ Re-run Briefcase once that installation is complete.
 
 def ensure_xcode_is_installed(
     command,
-    xcode_location=None,
     min_version=None,
+    xcode_location="/Applications/Xcode.app",
 ):
     """Determine if Xcode is installed; and if so, that it meets minimum
     version requirements.
@@ -127,48 +127,41 @@ def ensure_xcode_is_installed(
     that is installed doesn't meet the minimum requirement.
 
     :param command: The command that needs to perform the verification check.
-    :param xcode_location: The location where Xcode should be installed.
-        If not given, the location returned by `xcode-select -p` will be used.
     :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 xcode_location: The location where we expect to find an Xcode install.
+        Used for testing; defaults to ``/Applications/Xcode.app``.
     """
-    # Try the direct approach. Look for the Xcode folder that is created
-    # when you install from the App store.
-
-    if xcode_location is None:
-
-        try:
-            output = command.subprocess.check_output(
-                ["xcode-select", "-p"],
-                stderr=subprocess.STDOUT,
-            )
-            xcode_location = output.strip()
-        except subprocess.CalledProcessError as e:
-            raise BriefcaseCommandError(
-                """\
-Could not find Xcode installation.
+    # Check for *any* version of Xcode tools. xcode-select returns:
+    #  * The path to the currently active Xcode install; or
+    #  * error code 2 - No Xcode installation
+    try:
+        command.subprocess.check_output(
+            ["xcode-select", "-p"],
+            stderr=subprocess.STDOUT,
+        )
+    except subprocess.CalledProcessError as e:
+        raise BriefcaseCommandError(
+            """\
+Could not find an Xcode installation.
 
 To select an existing Xcode installation, run:
 
     $ sudo xcode-select --switch path/to/Xcode.app
 
-or install Xcode from the macOS App Store. Re-run Briefcase afterwards.
+or install Xcode from the macOS App Store. Once you have installed Xcode,
+you can re-run Briefcase.
 """
-            ) from e
-
-    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.
-"""
-        )
+        ) from e
 
     try:
+        # xcodebuild -version returns the version of Xcode that is currently
+        # selected. If the current Xcode is a commandline tools install,
+        # returns an error:
+        #   xcode-select: error: tool 'xcodebuild' requires Xcode, but active
+        #   developer directory '/Library/Developer/CommandLineTools' is a
+        #   command line tools instance
         output = command.subprocess.check_output(
             ["xcodebuild", "-version"],
             stderr=subprocess.STDOUT,
@@ -226,12 +219,26 @@ Re-run Briefcase once that installation is complete.
 
     except subprocess.CalledProcessError as e:
         if " is a command line tools instance" in e.output:
-            raise BriefcaseCommandError(
-                """\
-Xcode may be installed, but the active developer directory is a
-command line tools instance. To make the default Xcode install the
-active developer directory, run:
+            # Commandline tools are currently selected. Look for the existence
+            # of the default folder; if that folder doesn't exist, we can't
+            # conclude that Xcode *isn't* installed.
+            if Path(xcode_location).exists():
+                preamble = """\
+Xcode appears to be installed, but the active developer directory is the Xcode
+command line tools. To make Xcode the active developer directory, run:
+"""
+            else:
+                preamble = """\
+You have the Xcode command line tools installed; however, Briefcase requires
+a full Xcode install. Xcode can be downloaded from the macOS App Store.
 
+Once you have installed Xcode, you can make it the active developer directory
+by running:
+"""
+
+            raise BriefcaseCommandError(
+                preamble
+                + """
     $ sudo xcode-select --switch /Applications/Xcode.app
 
 Or, to use a version of Xcode installed in a non-default location:
@@ -245,7 +252,7 @@ and then re-run Briefcase.
         else:
             raise BriefcaseCommandError(
                 """\
-The Xcode install appears to exist, but Briefcase was unable to
+An Xcode install appears to exist, but Briefcase was unable to
 determine the current Xcode version. Running:
 
     $ xcodebuild -version


Test patch:
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index 4309981f..d5679c48 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -10,15 +10,19 @@ from briefcase.integrations.xcode import ensure_xcode_is_installed
 
 
 @pytest.fixture
-def xcode(tmp_path):
+def default_xcode_install_path(tmp_path):
+    return tmp_path / "Applications" / "Xcode.app"
+
+
+@pytest.fixture
+def xcode(default_xcode_install_path):
     """Create a dummy location for Xcode."""
-    xcode_location = tmp_path / "Xcode.app"
-    xcode_location.mkdir(parents=True, exist_ok=True)
-    return os.fsdecode(xcode_location)
+    default_xcode_install_path.mkdir(parents=True, exist_ok=True)
+    return os.fsdecode(default_xcode_install_path)
 
 
 def test_not_installed(tmp_path):
-    """If Xcode is not installed, raise an error."""
+    """If No Xcode is installed, raise an error."""
     command = mock.MagicMock()
     command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
         cmd=["xcode-select", "-p"], returncode=2
@@ -28,65 +32,192 @@ def test_not_installed(tmp_path):
     with pytest.raises(BriefcaseCommandError):
         ensure_xcode_is_installed(command)
 
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
+    )
+
+
+def test_custom_install_location(default_xcode_install_path, tmp_path):
+    """If Xcode is in a non-default location, that's fine."""
+    # Create a custom Xcode location
+    custom_xcode_location = tmp_path / "custom" / "Xcode.app"
+    custom_xcode_location.mkdir(parents=True, exist_ok=True)
 
-def test_not_installed_hardcoded_path(tmp_path):
-    """If Xcode is not installed at the given location, raise an error."""
     command = mock.MagicMock()
-    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
-        cmd=["xcodebuild", "-version"], returncode=1
+    command.subprocess.check_output.side_effect = [
+        os.fsdecode(custom_xcode_location) + "\n",  # xcode-select -p
+        "Xcode 13.3.1\nBuild version 11B500\n",  # xcodebuild -version
+    ]
+
+    ensure_xcode_is_installed(command, xcode_location=default_xcode_install_path)
+
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
-    # Test a location where Xcode *won't* be installed
-    with pytest.raises(BriefcaseCommandError):
-        ensure_xcode_is_installed(
-            command,
-            xcode_location=tmp_path / "Xcode.app",
-        )
 
-    # xcode-select was not invoked
-    command.subprocess.check_output.assert_not_called()
+def test_command_line_tools_only(default_xcode_install_path):
+    """If the cmdline tools are installed, but Xcode isn't, raise an error."""
+    command = mock.MagicMock()
+    command.subprocess.check_output.side_effect = [
+        "/Library/Developer/CommandLineTools\n",  # xcode-select -p
+        subprocess.CalledProcessError(
+            cmd=["xcodebuild", "-version"],
+            returncode=1,
+            output="xcode-select: error: tool 'xcodebuild' requires Xcode, but "
+            "active developer directory '/Library/Developer/CommandLineTools' "
+            "is a command line tools instance\n",
+        ),
+    ]
 
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"You have the Xcode command line tools installed",
+    ):
+        ensure_xcode_is_installed(command, xcode_location=default_xcode_install_path)
 
-def test_exists_but_command_line_tools_selected(xcode):
-    """If the Xcode folder exists, but cmd-line tools are selected, raise an
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
+    )
+
+
+def test_installed_but_command_line_tools_selected(default_xcode_install_path, xcode):
+    """If Xcode is installed, but the cmdline tools are selected raise an
     error."""
     command = mock.MagicMock()
-    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
-        cmd=["xcodebuild", "-version"], returncode=1
-    )
-    command.subprocess.check_output.side_effect.output = (
-        "xcode-select: error: tool 'xcodebuild' requires Xcode, but "
-        "active developer directory '/Library/Developer/CommandLineTools' "
-        "is a command line tools instance\n"
+    command.subprocess.check_output.side_effect = [
+        xcode + "\n",  # xcode-select -p
+        subprocess.CalledProcessError(
+            cmd=["xcodebuild", "-version"],
+            returncode=1,
+            output="xcode-select: error: tool 'xcodebuild' requires Xcode, but "
+            "active developer directory '/Library/Developer/CommandLineTools' "
+            "is a command line tools instance\n",
+        ),
+    ]
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Xcode appears to be installed, but the active developer directory ",
+    ):
+        ensure_xcode_is_installed(command, xcode_location=default_xcode_install_path)
+
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
-    with pytest.raises(BriefcaseCommandError, match=r"xcode-select --switch"):
-        ensure_xcode_is_installed(command, xcode_location=xcode)
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ["xcodebuild", "-version"],
-        stderr=subprocess.STDOUT,
+def test_custom_install_with_command_line_tools(default_xcode_install_path, tmp_path):
+    """If the cmdline tools are installed, and Xcode is in a non-default
+    location, raise an error."""
+    # Create a custom Xcode location
+    custom_xcode_location = tmp_path / "custom" / "Xcode.app"
+    custom_xcode_location.mkdir(parents=True, exist_ok=True)
+
+    command = mock.MagicMock()
+    command.subprocess.check_output.side_effect = [
+        "/Library/Developer/CommandLineTools\n",  # xcode-select -p
+        subprocess.CalledProcessError(
+            cmd=["xcodebuild", "-version"],
+            returncode=1,
+            output="xcode-select: error: tool 'xcodebuild' requires Xcode, but "
+            "active developer directory '/Library/Developer/CommandLineTools' "
+            "is a command line tools instance\n",
+        ),
+    ]
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"You have the Xcode command line tools installed",
+    ):
+        ensure_xcode_is_installed(command, xcode_location=default_xcode_install_path)
+
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
 
-def test_exists_but_corrupted(xcode):
+def test_installed_but_corrupted(xcode):
     """If the Xcode folder exists, but xcodebuild breaks, raise an error."""
     command = mock.MagicMock()
-    command.subprocess.check_output.side_effect = subprocess.CalledProcessError(
-        cmd=["xcodebuild", "-version"], returncode=1
-    )
-    command.subprocess.check_output.side_effect.output = "Badness occurred."
+    command.subprocess.check_output.side_effect = [
+        xcode + "\n",  # xcode-select -p
+        subprocess.CalledProcessError(
+            cmd=["xcodebuild", "-version"],
+            returncode=1,
+            output="Badness occurred",
+        ),
+    ]
 
     with pytest.raises(
         BriefcaseCommandError, match=r"should return the current Xcode version"
     ):
         ensure_xcode_is_installed(command, xcode_location=xcode)
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ["xcodebuild", "-version"],
-        stderr=subprocess.STDOUT,
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
 
@@ -94,17 +225,27 @@ def test_installed_no_minimum_version(xcode):
     """If Xcode is installed, but there's no minimum version, check is
     satisfied."""
     command = mock.MagicMock()
-    command.subprocess.check_output.return_value = (
-        "Xcode 11.2.1\nBuild version 11B500\n"
-    )
+    command.subprocess.check_output.side_effect = [
+        xcode + "\n",  # xcode-select -p
+        "Xcode 11.2.1\nBuild version 11B500\n",  # xcodebuild -version
+    ]
 
     # Check passes without an error.
     ensure_xcode_is_installed(command, xcode_location=xcode)
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ["xcodebuild", "-version"],
-        stderr=subprocess.STDOUT,
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
 
@@ -113,24 +254,35 @@ def test_installed_extra_output(capsys, xcode):
     # This specific output was seen in the wild with Xcode 13.2.1; see #668
     command = mock.MagicMock()
     command.logger = Log()
-    command.subprocess.check_output.return_value = "\n".join(
-        [
-            "objc[86306]: Class AMSupportURLConnectionDelegate is implemented in both /usr/lib/libauthinstall.dylib (0x20d17ab90) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b82c8). One of the two will be used. Which one is undefined."  # noqa: E501
-            "objc[86306]: Class AMSupportURLSession is implemented in both /usr/lib/libauthinstall.dylib (0x20d17abe0) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b8318). One of the two will be used. Which one is undefined.",  # noqa: E501
-            "Xcode 13.2.1",
-            "Build version 13C100",
-        ]
-    )
+    command.subprocess.check_output.side_effect = [
+        xcode + "\n",  # xcode-select -p
+        "\n".join(
+            [
+                "objc[86306]: Class AMSupportURLConnectionDelegate is implemented in both /usr/lib/libauthinstall.dylib (0x20d17ab90) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b82c8). One of the two will be used. Which one is undefined."  # noqa: E501
+                "objc[86306]: Class AMSupportURLSession is implemented in both /usr/lib/libauthinstall.dylib (0x20d17abe0) and /Library/Apple/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/MobileDevice (0x1084b8318). One of the two will be used. Which one is undefined.",  # noqa: E501
+                "Xcode 13.2.1",
+                "Build version 13C100",
+            ]
+        ),
+    ]
 
     # Check passes without an error.
     ensure_xcode_is_installed(command, xcode_location=xcode, min_version=(11, 1))
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ["xcodebuild", "-version"],
-        stderr=subprocess.STDOUT,
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
-
     # No warning generated.
     out = capsys.readouterr().out
     assert "WARNING" not in out
@@ -172,7 +324,6 @@ def test_installed_with_minimum_version_success(min_version, version, capsys, xc
     """Check XCode can meet a minimum version requirement."""
 
     def check_output_mock(cmd_list, *args, **kwargs):
-
         if cmd_list == ["xcode-select", "-p"]:
             return xcode + "\n"
 
@@ -224,9 +375,10 @@ def test_installed_with_minimum_version_success(min_version, version, capsys, xc
 def test_installed_with_minimum_version_failure(min_version, version, xcode):
     """Check XCode fail to meet a minimum version requirement."""
     command = mock.MagicMock()
-    command.subprocess.check_output.return_value = (
-        f"Xcode {version}\nBuild version 11B500\n"
-    )
+    command.subprocess.check_output.side_effect = [
+        xcode + "\n",  # xcode-select -p
+        f"Xcode {version}\nBuild version 11B500\n",  # xcodebuild -version
+    ]
 
     # Check raises an error.
     with pytest.raises(BriefcaseCommandError):
@@ -236,10 +388,19 @@ def test_installed_with_minimum_version_failure(min_version, version, xcode):
             xcode_location=xcode,
         )
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ["xcodebuild", "-version"],
-        stderr=subprocess.STDOUT,
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
 
@@ -247,7 +408,10 @@ def test_unexpected_version_output(capsys, xcode):
     """If xcodebuild returns unexpected output, assume it's ok..."""
     command = mock.MagicMock()
     command.logger = Log()
-    command.subprocess.check_output.return_value = "Wibble Wibble Wibble\n"
+    command.subprocess.check_output.side_effect = [
+        xcode + "\n",  # xcode-select -p
+        "Wibble Wibble Wibble\n",  # xcodebuild -version
+    ]
 
     # Check passes without an error...
     ensure_xcode_is_installed(
@@ -256,10 +420,19 @@ def test_unexpected_version_output(capsys, xcode):
         xcode_location=xcode,
     )
 
-    # xcode-select was invoked
-    command.subprocess.check_output.assert_called_once_with(
-        ["xcodebuild", "-version"],
-        stderr=subprocess.STDOUT,
+    # subprocess was invoked as expected
+    command.subprocess.check_output.assert_has_calls(
+        [
+            mock.call(
+                ["xcode-select", "-p"],
+                stderr=subprocess.STDOUT,
+            ),
+            mock.call(
+                ["xcodebuild", "-version"],
+                stderr=subprocess.STDOUT,
+            ),
+        ],
+        any_order=False,
     )
 
     # ...but stdout contains a warning