mirror of
https://github.com/conorluddy/ios-simulator-skill.git
synced 2026-09-14 20:07:12 +08:00
5dd6a84593
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
216 lines
9.4 KiB
Python
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
|