Files
conorluddy__ios-simulator-s…/tests/test_xcode_compat.py
Conor Luddy 5dd6a84593 fix(xcode27): keep xcrun working under the shim, auto-retire stale companions
Two follow-ups to #100, both found while investigating #101.

1. The shim's DEVELOPER_DIR broke every xcrun call in the same process.
   `common/__init__` sets DEVELOPER_DIR process-wide, and xcrun walks up from
   it looking for an enclosing .app bundle - a shim root named
   `xcode27-idb-shim` has none, so xcrun refused it outright:

     xcrun: error: unable to find Xcode installation from active developer
     path ".../xcode27-idb-shim/Contents/Developer"

   That broke all 22 scripts that shell out to `xcrun simctl` (screenshots,
   boot, io) on any Xcode 27 machine. Renaming the shim root to
   `Xcode27Shim.app` satisfies xcrun; idb is indifferent to the name. The
   pre-.app root is removed on next run.

2. A companion started before the shim stays broken for its whole lifetime -
   it inherits DEVELOPER_DIR once, at launch, and `idb connect` reuses the
   live process rather than replacing it. Scripts now retire any companion
   whose DEVELOPER_DIR is not the shim's.

   Killing the process alone is not enough: idb's registry at /tmp/idb/state
   still names the dead pid and its socket, so the next call dials a dead
   socket ("Connection refused") instead of spawning a replacement. Retired
   companions are evicted from the registry and their sockets unlinked, which
   removes the manual `pkill -f idb_companion` + `rm -f *.sock` dance from
   the #101 repro.

Verified on Xcode 27 / iOS 27.0 / iPhone 17 Pro: shim rebuilds from scratch,
xcrun and idb both work through it, a deliberately stale companion is retired
and recovered from transparently, and navigator.py taps land on screen.

Refs #100, #101
2026-09-12 07:49:07 +01:00

216 lines
9.4 KiB
Python

"""Xcode 27+ moved SimulatorKit.framework; idb-companion still expects the old path."""
from pathlib import Path
from unittest.mock import patch
import json
from common.xcode_compat import (
_evict_from_idb_registry,
_retire_stale_companions,
ensure_idb_companion_developer_dir,
)
def _make_xcode(
tmp_path: Path, *, shared_frameworks: bool, legacy_private_frameworks: bool
) -> Path:
"""Build a fake Xcode.app skeleton with just enough to exercise the shim."""
contents = tmp_path / "Xcode.app" / "Contents"
developer = contents / "Developer"
(developer / "Library").mkdir(parents=True)
(developer / "Toolchains").mkdir()
if legacy_private_frameworks:
(developer / "Library" / "PrivateFrameworks").mkdir()
(developer / "Library" / "PrivateFrameworks" / "SimulatorKit.framework").mkdir()
if shared_frameworks:
(contents / "SharedFrameworks" / "SimulatorKit.framework").mkdir(parents=True)
(contents / "Info.plist").write_text("<plist/>")
return developer
class TestEnsureIdbCompanionDeveloperDir:
def test_legacy_xcode_layout_is_untouched(self, tmp_path, monkeypatch):
"""Old Xcode with SimulatorKit already at the expected path: no shim, no env change."""
developer = _make_xcode(tmp_path, shared_frameworks=False, legacy_private_frameworks=True)
monkeypatch.delenv("DEVELOPER_DIR", raising=False)
with patch("common.xcode_compat._developer_dir", return_value=developer):
ensure_idb_companion_developer_dir()
assert "DEVELOPER_DIR" not in __import__("os").environ
def test_xcode27_layout_builds_shim_and_sets_developer_dir(self, tmp_path, monkeypatch):
"""New Xcode 27 layout (SimulatorKit under SharedFrameworks): shim built, env points at it."""
developer = _make_xcode(tmp_path, shared_frameworks=True, legacy_private_frameworks=False)
shim_root = tmp_path / "shim-home"
monkeypatch.delenv("DEVELOPER_DIR", raising=False)
with (
patch("common.xcode_compat._developer_dir", return_value=developer),
patch("common.xcode_compat.SHIM_ROOT", shim_root),
):
ensure_idb_companion_developer_dir()
shimmed_developer_dir = Path(__import__("os").environ["DEVELOPER_DIR"])
assert shimmed_developer_dir == shim_root / "Contents" / "Developer"
shimmed_simulator_kit = (
shimmed_developer_dir / "Library" / "PrivateFrameworks" / "SimulatorKit.framework"
)
assert (
shimmed_simulator_kit.resolve()
== (developer.parent / "SharedFrameworks" / "SimulatorKit.framework").resolve()
)
def test_neither_layout_present_leaves_developer_dir_alone(self, tmp_path, monkeypatch):
"""Xcode with SimulatorKit in neither location: not our bug to fix, don't touch env."""
developer = _make_xcode(tmp_path, shared_frameworks=False, legacy_private_frameworks=False)
monkeypatch.delenv("DEVELOPER_DIR", raising=False)
with patch("common.xcode_compat._developer_dir", return_value=developer):
ensure_idb_companion_developer_dir()
assert "DEVELOPER_DIR" not in __import__("os").environ
def test_opt_out_env_var_skips_entirely(self, tmp_path, monkeypatch):
developer = _make_xcode(tmp_path, shared_frameworks=True, legacy_private_frameworks=False)
monkeypatch.setenv("IOS_SIM_SKIP_XCODE27_SHIM", "1")
monkeypatch.delenv("DEVELOPER_DIR", raising=False)
with patch("common.xcode_compat._developer_dir", return_value=developer):
ensure_idb_companion_developer_dir()
assert "DEVELOPER_DIR" not in __import__("os").environ
def test_rerun_reuses_existing_shim_without_rebuilding(self, tmp_path, monkeypatch):
"""Calling twice (e.g. two scripts in one session) must not rebuild an already-correct shim."""
developer = _make_xcode(tmp_path, shared_frameworks=True, legacy_private_frameworks=False)
shim_root = tmp_path / "shim-home"
monkeypatch.delenv("DEVELOPER_DIR", raising=False)
with (
patch("common.xcode_compat._developer_dir", return_value=developer),
patch("common.xcode_compat.SHIM_ROOT", shim_root),
patch(
"common.xcode_compat._build_shim",
wraps=__import__("common.xcode_compat", fromlist=["_build_shim"])._build_shim,
) as build_shim,
):
ensure_idb_companion_developer_dir()
ensure_idb_companion_developer_dir()
assert build_shim.call_count == 1
class TestShimRootIsAnAppBundle:
"""xcrun refuses a DEVELOPER_DIR with no enclosing .app, and DEVELOPER_DIR is process-wide."""
def test_shim_developer_dir_sits_inside_an_app_bundle(self, tmp_path, monkeypatch):
developer = _make_xcode(tmp_path, shared_frameworks=True, legacy_private_frameworks=False)
shim_root = tmp_path / "Xcode27Shim.app"
monkeypatch.delenv("DEVELOPER_DIR", raising=False)
with (
patch("common.xcode_compat._developer_dir", return_value=developer),
patch("common.xcode_compat.SHIM_ROOT", shim_root),
patch("common.xcode_compat._retire_stale_companions"),
):
ensure_idb_companion_developer_dir()
shimmed = Path(__import__("os").environ["DEVELOPER_DIR"])
enclosing_bundle = shimmed.parent.parent
assert enclosing_bundle.suffix == ".app"
def test_default_shim_root_is_an_app_bundle(self):
from common.xcode_compat import SHIM_ROOT
assert SHIM_ROOT.suffix == ".app"
class TestRetireStaleCompanions:
"""A companion inherits DEVELOPER_DIR at launch; a pre-shim one stays broken for its life."""
def test_companion_outside_the_shim_is_killed(self):
shim = Path("/shim/Contents/Developer")
with (
patch("common.xcode_compat._running_companion_pids", return_value=[4242]),
patch("common.xcode_compat._companion_developer_dir", return_value=None),
patch("common.xcode_compat._evict_from_idb_registry"),
patch("common.xcode_compat._terminate") as terminate,
):
_retire_stale_companions(shim)
terminate.assert_called_once_with(4242)
def test_killed_companion_is_evicted_from_the_registry(self):
"""Leaving a dead pid in idb's registry makes the next call fail instead of respawn."""
shim = Path("/shim/Contents/Developer")
with (
patch("common.xcode_compat._running_companion_pids", return_value=[4242]),
patch("common.xcode_compat._companion_developer_dir", return_value=None),
patch("common.xcode_compat._terminate"),
patch("common.xcode_compat._evict_from_idb_registry") as evict,
):
_retire_stale_companions(shim)
evict.assert_called_once_with({4242})
def test_companion_already_under_the_shim_is_left_running(self):
shim = Path("/shim/Contents/Developer")
with (
patch("common.xcode_compat._running_companion_pids", return_value=[4242]),
patch("common.xcode_compat._companion_developer_dir", return_value=str(shim)),
patch("common.xcode_compat._evict_from_idb_registry") as evict,
patch("common.xcode_compat._terminate") as terminate,
):
_retire_stale_companions(shim)
terminate.assert_not_called()
evict.assert_not_called()
def test_no_companion_running_is_a_no_op(self):
with (
patch("common.xcode_compat._running_companion_pids", return_value=[]),
patch("common.xcode_compat._terminate") as terminate,
):
_retire_stale_companions(Path("/shim/Contents/Developer"))
terminate.assert_not_called()
class TestEvictFromIdbRegistry:
"""idb dials the socket its registry names; a dead entry must go, socket and all."""
def test_retired_entry_and_its_socket_are_removed(self, tmp_path):
socket_path = tmp_path / "dead_companion.sock"
socket_path.write_text("")
registry = tmp_path / "state"
registry.write_text(
json.dumps(
[
{"udid": "DEAD", "pid": 4242, "path": str(socket_path)},
{"udid": "LIVE", "pid": 777, "path": str(tmp_path / "live.sock")},
]
)
)
with patch(
"common.xcode_compat.Path",
side_effect=lambda p: registry if p == "/tmp/idb/state" else Path(p),
):
_evict_from_idb_registry({4242})
assert json.loads(registry.read_text()) == [
{"udid": "LIVE", "pid": 777, "path": str(tmp_path / "live.sock")}
]
assert not socket_path.exists()
def test_registry_without_retired_entries_is_left_untouched(self, tmp_path):
registry = tmp_path / "state"
original = json.dumps([{"udid": "LIVE", "pid": 777, "path": str(tmp_path / "live.sock")}])
registry.write_text(original)
with patch(
"common.xcode_compat.Path",
side_effect=lambda p: registry if p == "/tmp/idb/state" else Path(p),
):
_evict_from_idb_registry({4242})
assert registry.read_text() == original
def test_missing_registry_is_a_no_op(self, tmp_path):
missing = tmp_path / "absent"
with patch(
"common.xcode_compat.Path",
side_effect=lambda p: missing if p == "/tmp/idb/state" else Path(p),
):
_evict_from_idb_registry({4242}) # must not raise