Skip to content

Commit 28ef1a4

Browse files
authored
fix: warn when multiple simulators are booted (#94)
`get_booted_device_udid()` silently returned the first booted simulator. When more than one is booted (or a stale idb companion lingers), gesture and tap commands resolve to a device other than the one being watched — idb reports success on the wrong device, so swipes/scrolls silently no-op with no error. Keep the first-match default but print a stderr warning on ambiguity naming the selected device and advising an explicit --udid. Add `get_booted_device_udids()` so callers can detect the multi-device case, plus regression tests covering single/multiple/none/failure listings.
1 parent 58e9815 commit 28ef1a4

3 files changed

Lines changed: 133 additions & 18 deletions

File tree

ios-simulator-skill/skills/ios-simulator-skill/scripts/common/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
build_idb_command,
1717
build_simctl_command,
1818
get_booted_device_udid,
19+
get_booted_device_udids,
1920
get_device_screen_size,
2021
resolve_udid,
2122
transform_screenshot_coords,
@@ -49,6 +50,7 @@
4950
"generate_screenshot_name",
5051
"get_accessibility_tree",
5152
"get_booted_device_udid",
53+
"get_booted_device_udids",
5254
"get_cache",
5355
"get_device_screen_size",
5456
"get_screen_size",

ios-simulator-skill/skills/ios-simulator-skill/scripts/common/device_utils.py

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import json
1818
import re
1919
import subprocess
20+
import sys
2021

2122

2223
def build_simctl_command(
@@ -121,21 +122,20 @@ def build_idb_command(
121122
return cmd
122123

123124

124-
def get_booted_device_udid() -> str | None:
125+
def get_booted_device_udids() -> list[str]:
125126
"""
126-
Auto-detect currently booted simulator UDID.
127+
List the UDIDs of every currently booted simulator.
127128
128-
Queries xcrun simctl for booted devices and returns first match.
129+
Queries `xcrun simctl list devices booted` and extracts each UDID in the
130+
order reported.
129131
130132
Returns:
131-
UDID of booted simulator, or None if no simulator is booted.
133+
UDIDs of all booted simulators, or an empty list if none are booted
134+
(or the query fails).
132135
133136
Example:
134-
udid = get_booted_device_udid()
135-
if udid:
136-
print(f"Booted simulator: {udid}")
137-
else:
138-
print("No simulator is currently booted")
137+
udids = get_booted_device_udids()
138+
# ["ABC123-...", "DEF456-..."] when two simulators are running
139139
"""
140140
try:
141141
result = subprocess.run(
@@ -144,18 +144,48 @@ def get_booted_device_udid() -> str | None:
144144
text=True,
145145
check=True,
146146
)
147+
except subprocess.CalledProcessError:
148+
return []
147149

148-
# Parse output to find UDID
149-
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
150-
for line in result.stdout.split("\n"):
151-
# Look for UUID pattern in parentheses
152-
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
153-
if match:
154-
return match.group(1)
150+
# Format: " iPhone 16 Pro (ABC123-DEF456) (Booted)"
151+
udids: list[str] = []
152+
for line in result.stdout.split("\n"):
153+
match = re.search(r"\(([A-F0-9\-]{36})\)", line)
154+
if match:
155+
udids.append(match.group(1))
156+
return udids
155157

158+
159+
def get_booted_device_udid() -> str | None:
160+
"""
161+
Auto-detect a booted simulator UDID.
162+
163+
Returns the first booted simulator. When more than one simulator is booted
164+
the choice is ambiguous — gesture/tap commands can silently target a device
165+
other than the one you are watching (idb reports success on the wrong
166+
device, so nothing appears to happen). In that case a warning is printed to
167+
stderr naming the selected device and advising an explicit ``--udid``.
168+
169+
Returns:
170+
UDID of a booted simulator, or None if no simulator is booted.
171+
172+
Example:
173+
udid = get_booted_device_udid()
174+
if udid:
175+
print(f"Booted simulator: {udid}")
176+
else:
177+
print("No simulator is currently booted")
178+
"""
179+
udids = get_booted_device_udids()
180+
if not udids:
156181
return None
157-
except subprocess.CalledProcessError:
158-
return None
182+
if len(udids) > 1:
183+
print(
184+
f"Warning: {len(udids)} booted simulators detected ({', '.join(udids)}). "
185+
f"Auto-selecting {udids[0]} — pass --udid to target a specific device.",
186+
file=sys.stderr,
187+
)
188+
return udids[0]
159189

160190

161191
def resolve_udid(udid_arg: str | None) -> str:
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Regression tests for ambiguous booted-device auto-selection.
2+
3+
When more than one simulator is booted, `get_booted_device_udid()` used to
4+
silently return the first match. Gesture/tap commands resolve their target via
5+
this helper, so they could land on a simulator other than the one being watched
6+
(idb reports success on the wrong device, so nothing appears to happen). The fix
7+
keeps the first-match behaviour but emits a stderr warning on ambiguity, and
8+
exposes `get_booted_device_udids()` so callers can detect the multi-device case.
9+
"""
10+
11+
import subprocess
12+
13+
import pytest
14+
from common.device_utils import get_booted_device_udid, get_booted_device_udids
15+
16+
UDID_A = "AAAAAAAA-1111-2222-3333-444444444444"
17+
UDID_B = "BBBBBBBB-5555-6666-7777-888888888888"
18+
19+
20+
def _fake_simctl(stdout: str):
21+
"""Return a subprocess.run stub that yields the given booted-device listing."""
22+
23+
def _run(*_args, **_kwargs):
24+
return subprocess.CompletedProcess(args=[], returncode=0, stdout=stdout, stderr="")
25+
26+
return _run
27+
28+
29+
# === get_booted_device_udids ===
30+
31+
32+
def test_lists_all_booted_udids(monkeypatch):
33+
listing = (
34+
f"-- iOS 26.2 --\n"
35+
f" iPhone 17 Pro ({UDID_A}) (Booted)\n"
36+
f" iPad Pro ({UDID_B}) (Booted)\n"
37+
)
38+
monkeypatch.setattr(subprocess, "run", _fake_simctl(listing))
39+
assert get_booted_device_udids() == [UDID_A, UDID_B]
40+
41+
42+
def test_returns_empty_when_none_booted(monkeypatch):
43+
monkeypatch.setattr(subprocess, "run", _fake_simctl("== Devices ==\n-- iOS 26.2 --\n"))
44+
assert get_booted_device_udids() == []
45+
46+
47+
def test_returns_empty_when_simctl_fails(monkeypatch):
48+
def _boom(*_args, **_kwargs):
49+
raise subprocess.CalledProcessError(1, "simctl")
50+
51+
monkeypatch.setattr(subprocess, "run", _boom)
52+
assert get_booted_device_udids() == []
53+
54+
55+
# === get_booted_device_udid ===
56+
57+
58+
def test_single_device_returns_udid_without_warning(monkeypatch, capsys):
59+
listing = f"-- iOS 26.2 --\n iPhone 17 Pro ({UDID_A}) (Booted)\n"
60+
monkeypatch.setattr(subprocess, "run", _fake_simctl(listing))
61+
62+
assert get_booted_device_udid() == UDID_A
63+
assert capsys.readouterr().err == ""
64+
65+
66+
def test_multiple_devices_warns_and_picks_first(monkeypatch, capsys):
67+
listing = f" iPhone 17 Pro ({UDID_A}) (Booted)\n iPad Pro ({UDID_B}) (Booted)\n"
68+
monkeypatch.setattr(subprocess, "run", _fake_simctl(listing))
69+
70+
assert get_booted_device_udid() == UDID_A
71+
stderr = capsys.readouterr().err
72+
assert "2 booted simulators" in stderr
73+
assert UDID_A in stderr and UDID_B in stderr
74+
assert "--udid" in stderr
75+
76+
77+
def test_no_device_returns_none(monkeypatch):
78+
monkeypatch.setattr(subprocess, "run", _fake_simctl("-- iOS 26.2 --\n"))
79+
assert get_booted_device_udid() is None
80+
81+
82+
if __name__ == "__main__":
83+
raise SystemExit(pytest.main([__file__, "-v"]))

0 commit comments

Comments
 (0)