Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[scan] Filter simulators with version greater than SDK version of active Xcode installation when choosing default #21677

Merged
merged 8 commits into from Dec 14, 2023
63 changes: 46 additions & 17 deletions scan/lib/scan/detect_values.rb
@@ -1,10 +1,18 @@
require 'fastlane_core/device_manager'
require 'fastlane_core/project'
require 'pathname'
require_relative 'module'

module Scan
# This class detects all kinds of default values
class DetectValues
PLATFORM_SIMULATOR_NAME = {
'iOS' => 'iPhoneSimulator',
'tvOS' => 'AppleTVSimulator',
'watchOS' => 'WatchSimulator',
'visionOS' => 'XRSimulator'
}.freeze

# This is needed as these are more complex default values
# Returns the finished config object
def self.set_additional_default_values
Expand Down Expand Up @@ -100,6 +108,41 @@ def self.filter_simulators(simulators, operator = :greater_than_or_equal, deploy
end
end

def self.detect_sdk_version(platform)
UI.crash!("Unknown platform: #{platform}") unless PLATFORM_SIMULATOR_NAME.key?(platform)
simulator_name = PLATFORM_SIMULATOR_NAME[platform]
@sdk_versions ||= {}
@sdk_versions[platform] ||= begin
platform_path = Pathname.new("Platforms/#{simulator_name}.platform/Developer/SDKs/")
sdks_path = Pathname.new(FastlaneCore::Helper.xcode_path).join(platform_path)
default_sdk_path = sdks_path.join("#{simulator_name}.sdk")
sdk_path = sdks_path.children.find { |child| child.symlink? && child.realpath == default_sdk_path }
UI.crash!("Unable to find default #{simulator_name} SDK version from SDKs: #{sdks_path.children}") unless sdk_path

version = /#{Regexp.quote(simulator_name)}(?<val>.*)\.sdk/.match(sdk_path.basename.to_s)
UI.crash!("Could not determine SDK version from #{sdk_path}") unless version && version[:val] != ''

begin
Gem::Version.new(version[:val])
rescue ArgumentError => e
UI.crash!("Could not parse SDK version: #{e.message}")
end
end
end
wuaar1003 marked this conversation as resolved.
Show resolved Hide resolved

def self.compatible_with_sdk(sim)
default_sdk_version = detect_sdk_version(sim.os_type)
Gem::Version.new(sim.os_version) <= default_sdk_version || sim.os_version.start_with?(default_sdk_version.version)
end

def self.highest_compatible_simulator(simulators, name)
simulators
.select { |sim| sim.name == name && compatible_with_sdk(sim) }
.reverse
.sort_by! { |sim| Gem::Version.new(sim.os_version) }
.last
end

def self.regular_expression_for_split_on_whitespace_followed_by_parenthesized_version
# %r{
# \s # a whitespace character
Expand Down Expand Up @@ -137,26 +180,19 @@ def self.detect_simulator(devices, requested_os_type, deployment_target_key, def
# We create 2 lambdas, which we iterate over later on
# If the first lambda `matches` found a simulator to use
# we'll never call the second one

matches = lambda do
set_of_simulators = devices.inject(
Set.new # of simulators
) do |set, device_string|
pieces = device_string.split(regular_expression_for_split_on_whitespace_followed_by_parenthesized_version)

selector = ->(sim) { pieces.count > 0 && sim.name == pieces.first }

display_device = "'#{device_string}'"

set + (
if pieces.count == 0
[] # empty array
elsif pieces.count == 1
simulators
.select(&selector)
.reverse # more efficient, because `simctl` prints higher versions first
.sort_by! { |sim| Gem::Version.new(sim.os_version) }
.pop(1)
[ highest_compatible_simulator(simulators, pieces.first) ]
else # pieces.count == 2 -- mathematically, because of the 'end of line' part of our regular expression
version = pieces[1].tr('()', '')
display_device = "'#{pieces[0]}' with version #{version}"
Expand All @@ -168,7 +204,7 @@ def self.detect_simulator(devices, requested_os_type, deployment_target_key, def
"of deployment target (#{deployment_target_version})")
end
end
filter_simulators(simulators, :equal, version).tap(&potential_emptiness_error).select(&selector)
filter_simulators(simulators, :equal, version).tap(&potential_emptiness_error).select { |sim| sim.name == pieces.first }
end
).tap do |array|
if array.empty?
Expand All @@ -185,13 +221,7 @@ def self.detect_simulator(devices, requested_os_type, deployment_target_key, def
default = lambda do
UI.error("Couldn't find any matching simulators for '#{devices}' - falling back to default simulator") if (devices || []).count > 0

result = Array(
simulators
.select { |sim| sim.name == default_device_name }
.reverse # more efficient, because `simctl` prints higher versions first
.sort_by! { |sim| Gem::Version.new(sim.os_version) }
.last || simulators.first
)
result = [ highest_compatible_simulator(simulators, default_device_name) || simulators.first ]

UI.message("Found simulator \"#{result.first.name} (#{result.first.os_version})\"") if result.first

Expand Down Expand Up @@ -248,7 +278,6 @@ def self.get_deployment_target_version(deployment_target_key)
version = Scan.config[:deployment_target_version]
version ||= Scan.project.build_settings(key: deployment_target_key) if Scan.project
version ||= 0

return version
end
end
Expand Down
153 changes: 153 additions & 0 deletions scan/spec/detect_values_spec.rb
Expand Up @@ -51,6 +51,159 @@
end
end

describe "#detect_sdk_version" do
it 'informs users of unknown platform name' do
expect do
Scan::DetectValues.detect_sdk_version('test')
end.to raise_error(FastlaneCore::Interface::FastlaneCrash, "Unknown platform: test")
end
%w[iOS tvOS watchOS].each do |platform|
simulator_name = Scan::DetectValues::PLATFORM_SIMULATOR_NAME[platform]

it "returns an error if there is no default #{platform} SDK symlink" do
default_path = double("path/to/default.sdk")
platform_path = double("path/to/sdks")
sdks_path = double("full/path/to/sdks",
children: [
double("path/to/other.sdk", symlink?: false),
double("path/to/some.sdk", symlink?: false),
double("path/to/default.sdk", symlink?: false)
])

allow(Pathname).to receive(:new).with("Platforms/#{simulator_name}.platform/Developer/SDKs/").and_return(platform_path)
allow(FastlaneCore::Helper).to receive(:xcode_path).and_return('mock')
allow(Pathname).to receive(:new).with('mock').and_return(sdks_path)
allow(sdks_path).to receive(:join).with(platform_path).and_return(sdks_path)
allow(sdks_path).to receive(:join).with("#{simulator_name}.sdk").and_return(default_path)

expect do
Scan::DetectValues.detect_sdk_version(platform)
end.to raise_error(FastlaneCore::Interface::FastlaneCrash, "Unable to find default #{simulator_name} SDK version from SDKs: #{sdks_path.children}")
end

it "returns an error on failure to determine #{platform} SDK version from filename" do
lacostej marked this conversation as resolved.
Show resolved Hide resolved
default_path = double("path/to/default.sdk")
platform_path = double("path/to/sdks")
some_path = double("path/to/some.sdk", symlink?: true, realpath: default_path, basename: "#{simulator_name}.sdk")
sdks_path = double("full/path/to/sdks",
children: [
double("path/to/other.sdk", symlink?: false),
some_path,
double("path/to/default.sdk", symlink?: false)
])

allow(Pathname).to receive(:new).with("Platforms/#{simulator_name}.platform/Developer/SDKs/").and_return(platform_path)
allow(FastlaneCore::Helper).to receive(:xcode_path).and_return('mock')
allow(Pathname).to receive(:new).with('mock').and_return(sdks_path)
allow(sdks_path).to receive(:join).with(platform_path).and_return(sdks_path)
allow(sdks_path).to receive(:join).with("#{simulator_name}.sdk").and_return(default_path)

expect do
Scan::DetectValues.detect_sdk_version(platform)
end.to raise_error(FastlaneCore::Interface::FastlaneCrash, "Could not determine SDK version from #{some_path}")
end

it "returns an error on failure to parse #{platform} SDK version from filename" do
default_path = double("path/to/default.sdk")
platform_path = double("path/to/sdks")
some_path = double("path/to/some.sdk", symlink?: true, realpath: default_path, basename: "#{simulator_name}asdf17g.sdk")
sdks_path = double("full/path/to/sdks",
children: [
double("path/to/other.sdk", symlink?: false),
some_path,
double("path/to/default.sdk", symlink?: false)
])

allow(Pathname).to receive(:new).with("Platforms/#{simulator_name}.platform/Developer/SDKs/").and_return(platform_path)
allow(FastlaneCore::Helper).to receive(:xcode_path).and_return('mock')
allow(Pathname).to receive(:new).with('mock').and_return(sdks_path)
allow(sdks_path).to receive(:join).with(platform_path).and_return(sdks_path)
allow(sdks_path).to receive(:join).with("#{simulator_name}.sdk").and_return(default_path)

expect do
Scan::DetectValues.detect_sdk_version(platform)
end.to raise_error(FastlaneCore::Interface::FastlaneCrash, "Could not parse SDK version: Malformed version number string asdf17g")
end

it "detects the expected default for #{platform}" do
default_path = double("path/to/default.sdk")
platform_path = double("path/to/sdks")
target_version = "17.0"
sdks_path = double("full/path/to/sdks",
children: [
double("path/to/other.sdk", symlink?: false),
double("path/to/some.sdk", symlink?: true, realpath: default_path, basename: "#{simulator_name}#{target_version}.sdk"),
double("path/to/default.sdk", symlink?: false)
])

allow(Pathname).to receive(:new).with("Platforms/#{simulator_name}.platform/Developer/SDKs/").and_return(platform_path)
allow(FastlaneCore::Helper).to receive(:xcode_path).and_return('mock')
allow(Pathname).to receive(:new).with('mock').and_return(sdks_path)
allow(sdks_path).to receive(:join).with(platform_path).and_return(sdks_path)
allow(sdks_path).to receive(:join).with("#{simulator_name}.sdk").and_return(default_path)

expect(Scan::DetectValues.detect_sdk_version(platform.to_s)).to equal(Gem::Version.new(target_version))
end
end
end

describe "#detect_simulator" do
it 'returns simulators for requested devices', requires_xcodebuild: true do
simctl_device_output = double("simctl device output", read: File.read('./scan/spec/fixtures/DeviceManagerSimctlOutputXcode15'))
expect(Open3).to receive(:popen3).with("xcrun simctl list devices").and_yield(nil, simctl_device_output, nil, nil)

simctl_runtime_output = double("simctl runtime output", read: "line\n")
allow(Open3).to receive(:popen3).with("xcrun simctl list runtimes").and_yield(nil, simctl_runtime_output, nil, nil)

allow(Scan::DetectValues).to receive(:detect_sdk_version).with('iOS').and_return(Gem::Version.new('17.0'))
allow(Scan::DetectValues).to receive(:detect_sdk_version).with('tvOS').and_return(Gem::Version.new('17.0'))
allow(Scan::DetectValues).to receive(:detect_sdk_version).with('watchOS').and_return(Gem::Version.new('10.0'))

devices = ['iPhone 14 Pro Max', 'Apple TV 4K (3rd generation)', 'Apple Watch Ultra (49mm)']
simulators = Scan::DetectValues.detect_simulator(devices, '', '', '', nil)

expect(simulators.count).to eq(3)
expect(simulators[0]).to have_attributes(
name: "iPhone 14 Pro Max", os_type: "iOS", os_version: "17.0"
)
expect(simulators[1]).to have_attributes(
name: "Apple TV 4K (3rd generation)", os_type: "tvOS", os_version: "17.0"
)
expect(simulators[2]).to have_attributes(
name: "Apple Watch Ultra (49mm)", os_type: "watchOS", os_version: "10.0"
)
end

it 'filters out simulators newer than what the current Xcode SDK supports', requires_xcodebuild: true do
simctl_device_output = double("simctl device output", read: File.read('./scan/spec/fixtures/DeviceManagerSimctlOutputXcode14'))
expect(Open3).to receive(:popen3).with("xcrun simctl list devices").and_yield(nil, simctl_device_output, nil, nil)

simctl_runtime_output = double("simctl runtime output", read: "line\n")
allow(Open3).to receive(:popen3).with("xcrun simctl list runtimes").and_yield(nil, simctl_runtime_output, nil, nil)

allow(Scan::DetectValues).to receive(:detect_sdk_version).with('iOS').and_return(Gem::Version.new('16.4'))
allow(Scan::DetectValues).to receive(:detect_sdk_version).with('tvOS').and_return(Gem::Version.new('16.4'))
allow(Scan::DetectValues).to receive(:detect_sdk_version).with('watchOS').and_return(Gem::Version.new('9.4'))

devices = ['iPhone 14 Pro Max', 'iPad Pro (12.9-inch) (6th generation) (16.1)', 'Apple TV 4K (3rd generation)', 'Apple Watch Ultra (49mm)']
simulators = Scan::DetectValues.detect_simulator(devices, '', '', '', nil)

expect(simulators.count).to eq(4)
expect(simulators[0]).to have_attributes(
name: "iPhone 14 Pro Max", os_type: "iOS", os_version: "16.4"
)
expect(simulators[1]).to have_attributes(
name: "iPad Pro (12.9-inch) (6th generation)", os_type: "iOS", os_version: "16.1"
)
expect(simulators[2]).to have_attributes(
name: "Apple TV 4K (3rd generation)", os_type: "tvOS", os_version: "16.4"
)
expect(simulators[3]).to have_attributes(
name: "Apple Watch Ultra (49mm)", os_type: "watchOS", os_version: "9.4"
)
end
end

describe "#detect_destination" do
it "ios", requires_xcodebuild: true do
options = { project: "./scan/examples/standard/app.xcodeproj" }
Expand Down
25 changes: 25 additions & 0 deletions scan/spec/fixtures/DeviceManagerSimctlOutputXcode14
@@ -0,0 +1,25 @@
== Devices ==
-- iOS 16.1 --
iPhone 14 Pro Max (01F8450A-7C9A-474B-BC8C-1F75F9671CC1) (Shutdown)
iPad Pro (12.9-inch) (6th generation) (D4A359E0-A0A7-407B-8909-217ED5C0E27C) (Shutdown)
-- iOS 16.4 --
iPhone 14 Pro Max (0200E1C9-D6D7-4C1E-AF67-40DD0598EEFF) (Shutdown)
iPad Pro (12.9-inch) (6th generation) (D4A359E0-A0A7-407B-8909-217ED5C0E27C) (Shutdown)
-- iOS 17.0 --
iPhone 14 Pro Max (530756ED-F705-4A8A-AA0E-704F72B7FEE5) (Shutdown)
iPad Pro (12.9-inch) (6th generation) (AF0DB812-330D-4030-813E-F7437F1F2492) (Shutdown)
iPhone 15 (E7A4A3FF-51A8-4AB5-9515-2D07E48E15CE) (Shutdown) (unavailable, device type profile not found)
iPhone 15 Plus (CA203CC7-C896-4467-8595-3A3432BD2F98) (Shutdown) (unavailable, device type profile not found)
iPhone 15 Pro (80A21777-0BC1-478E-9C0E-BAFACF126BE3) (Shutdown) (unavailable, device type profile not found)
iPhone 15 Pro Max (20952C59-B21A-49D8-A09F-4DB0983018C5) (Shutdown) (unavailable, device type profile not found)
-- tvOS 16.4 --
Apple TV 4K (3rd generation) (16B3AE21-BB25-463E-8392-1224DFFE776D) (Shutdown)
-- tvOS 17.0 --
Apple TV 4K (3rd generation) (AB921238-6D71-4AB3-A3AD-F64266AE66DD) (Shutdown)
-- watchOS 9.4 --
Apple Watch Ultra (49mm) (8EB51A76-1821-47F0-91C4-53F2D1634375) (Shutdown)
-- watchOS 10.0 --
Apple Watch Ultra (49mm) (64F04E58-1FE2-455B-8438-A7C662AA0CE4) (Shutdown)
Apple Watch Series 9 (41mm) (FAC17A8B-0DD4-4980-BC58-914E02DB2973) (Shutdown) (unavailable, device type profile not found)
Apple Watch Series 9 (45mm) (986D9C9F-705A-405F-B6F1-0E3C9FC04923) (Shutdown) (unavailable, device type profile not found)
Apple Watch Ultra 2 (49mm) (7E89824D-0A5A-464B-8B08-E0CBE3297DFA) (Shutdown) (unavailable, device type profile not found)
11 changes: 11 additions & 0 deletions scan/spec/fixtures/DeviceManagerSimctlOutputXcode15
@@ -0,0 +1,11 @@
== Devices ==
-- iOS 17.0 --
iPhone 14 Pro Max (530756ED-F705-4A8A-AA0E-704F72B7FEE5) (Shutdown)
-- tvOS 16.4 --
Apple TV 4K (3rd generation) (16B3AE21-BB25-463E-8392-1224DFFE776D) (Shutdown)
-- tvOS 17.0 --
Apple TV 4K (3rd generation) (AB921238-6D71-4AB3-A3AD-F64266AE66DD) (Shutdown)
-- watchOS 10.0 --
Apple Watch Ultra (49mm) (64F04E58-1FE2-455B-8438-A7C662AA0CE4) (Shutdown)
-- Unavailable: com.apple.CoreSimulator.SimRuntime.iOS-16-4 --
iPhone 14 Pro Max (0200E1C9-D6D7-4C1E-AF67-40DD0598EEFF) (Shutdown) (unavailable, runtime profile not found using "System" match policy)