class XCKnife::TestDumperHelper

Constants

TestSpecification

Attributes

logger[R]
testroot[R]

Public Class Methods

new(device_id, max_retry_count, debug, logger, dylib_logfile_path, naive_dump_bundle_names: [], skip_dump_bundle_names: [], simctl_timeout: 0) click to toggle source

rubocop:disable Metrics/ParameterLists

# File lib/xcknife/test_dumper.rb, line 150
def initialize(device_id, max_retry_count, debug, logger, dylib_logfile_path,
               naive_dump_bundle_names: [], skip_dump_bundle_names: [], simctl_timeout: 0)
  @xcode_path = `xcode-select -p`.strip
  @simctl_path = `xcrun -f simctl`.strip
  @nm_path = `xcrun -f nm`.strip
  @swift_path = `xcrun -f swift`.strip
  @platforms_path = File.join(@xcode_path, 'Platforms')
  @platform_path = File.join(@platforms_path, 'iPhoneSimulator.platform')
  @sdk_path = File.join(@platform_path, 'Developer/SDKs/iPhoneSimulator.sdk')
  @testroot = nil
  @device_id = device_id
  @max_retry_count = max_retry_count
  @simctl_timeout = simctl_timeout
  @logger = logger
  @debug = debug
  @dylib_logfile_path = dylib_logfile_path if dylib_logfile_path
  @naive_dump_bundle_names = naive_dump_bundle_names
  @skip_dump_bundle_names = skip_dump_bundle_names
end

Public Instance Methods

call(derived_data_folder, list_folder, extra_environment_variables = {}) click to toggle source

rubocop:enable Metrics/ParameterLists

# File lib/xcknife/test_dumper.rb, line 171
def call(derived_data_folder, list_folder, extra_environment_variables = {})
  @testroot = File.join(derived_data_folder, 'Build', 'Products')
  xctestrun_file = Dir[File.join(@testroot, '*.xctestrun')].first
  raise ArgumentError, "No xctestrun on #{@testroot}" if xctestrun_file.nil?

  xctestrun_as_json = `plutil -convert json -o - "#{xctestrun_file}"`
  FileUtils.mkdir_p(list_folder)
  list_tests(JSON.parse(xctestrun_as_json), list_folder, extra_environment_variables)
end

Private Instance Methods

call_simctl(args, env: {}, **spawn_opts) click to toggle source
# File lib/xcknife/test_dumper.rb, line 421
def call_simctl(args, env: {}, **spawn_opts)
  args = wrapped_simctl(args)
  cmd = Shellwords.shelljoin(args)
  puts "Running:\n$ #{cmd}"
  logger.info { "Environment variables:\n #{env.pretty_print_inspect}" }

  ret = system(env, *args, **spawn_opts)
  puts "Simctl errored with the following env:\n #{env.pretty_print_inspect}" unless ret
  ret
end
discover_tests_to_skip(test_bundle) click to toggle source
# File lib/xcknife/test_dumper.rb, line 330
def discover_tests_to_skip(test_bundle)
  identifier_for_test_method = '/'
  skip_test_identifiers = test_bundle['SkipTestIdentifiers'] || []
  skip_test_identifiers.reject { |i| i.include?(identifier_for_test_method) }.to_set
end
dylib_logfile_path() click to toggle source
# File lib/xcknife/test_dumper.rb, line 432
def dylib_logfile_path
  @dylib_logfile_path ||= '/tmp/xcknife_testdumper_dylib.log'
end
gtimeout() click to toggle source
# File lib/xcknife/test_dumper.rb, line 344
def gtimeout
  return [] unless @simctl_timeout.positive?

  path = gtimeout_path
  if path.empty?
    puts "warning: simctl_timeout specified but 'gtimeout' is not installed. The specified timeout will be ignored."
    return []
  end

  [path, '-k', '5', @simctl_timeout.to_s]
end
gtimeout_path() click to toggle source
# File lib/xcknife/test_dumper.rb, line 356
def gtimeout_path
  `which gtimeout`.strip
end
inject_vars(env, test_host) click to toggle source
# File lib/xcknife/test_dumper.rb, line 366
def inject_vars(env, test_host)
  env.each do |k, v|
    env[k] = replace_vars(v || '', test_host)
  end
end
install_app(test_host_path) click to toggle source
# File lib/xcknife/test_dumper.rb, line 378
def install_app(test_host_path)
  retries_count = 0
  max_retry_count = 3
  until (retries_count > max_retry_count) || call_simctl(['install', @device_id, test_host_path])
    retries_count += 1
    call_simctl ['shutdown', @device_id]
    call_simctl ['boot', @device_id]
    sleep 1.0
  end

  raise TestDumpError, "Installing #{test_host_path} failed" if retries_count > max_retry_count
end
list_single_test(list_folder, test_bundle, test_bundle_name) click to toggle source
# File lib/xcknife/test_dumper.rb, line 303
def list_single_test(list_folder, test_bundle, test_bundle_name)
  output_methods(list_folder, test_bundle, test_bundle_name) do
    [{ class: test_bundle_name, method: 'test' }]
  end
end
list_tests(xctestrun, list_folder, extra_environment_variables) click to toggle source

This executes naive test dumping in parallel by queueing up items onto a work queue to process with 1 new thread per processor. Results are placed onto a threadsafe spec queue to avoid writing to an object between threads, then popped off re-inserting them to our list of test results. rubocop:disable Metrics/CyclomaticComplexity

# File lib/xcknife/test_dumper.rb, line 201
def list_tests(xctestrun, list_folder, extra_environment_variables)
  xctestrun.reject! { |test_bundle_name, _| test_bundle_name == '__xctestrun_metadata__' }

  test_runs_by_method = test_groups(xctestrun)
  spec_queue = Queue.new
  nm_bundle_queue = Queue.new
  results = []
  single_tests = test_runs_by_method['single'] || []
  nm_tests = test_runs_by_method['nm'] || []
  simctl_tests = test_runs_by_method['simctl'] || []

  single_tests.each do |test_bundle_name, test_bundle|
    logger.info { "Skipping dumping tests in `#{test_bundle_name}` -- writing out fake event" }
    spec_queue << list_single_test(list_folder, test_bundle, test_bundle_name)
  end

  simctl_tests.each do |test_bundle_name, test_bundle|
    test_spec = list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
    wait_test_dumper_completion(test_spec.json_stream_file)

    spec_queue << test_spec
  end

  nm_tests.each { |item| nm_bundle_queue << item }

  [Etc.nprocessors, nm_bundle_queue.size].min.times.map do
    nm_bundle_queue << :stop

    Thread.new do
      Thread.current.abort_on_exception = true

      until (item = nm_bundle_queue.pop) == :stop
        test_bundle_name, test_bundle = item
        spec_queue << list_tests_with_nm(list_folder, test_bundle, test_bundle_name)
      end
    end
  end.each(&:join)

  results << spec_queue.pop until spec_queue.empty?

  results
end
list_tests_with_nm(list_folder, test_bundle, test_bundle_name) click to toggle source

Improvement?: assume that everything in the historical info is correct, so dont simctl or nm, and just spit out exactly what it said the classes were

# File lib/xcknife/test_dumper.rb, line 289
def list_tests_with_nm(list_folder, test_bundle, test_bundle_name)
  output_methods(list_folder, test_bundle, test_bundle_name) do |test_bundle_path|
    methods = []
    swift_demangled_nm(test_bundle_path) do |output|
      output.each_line do |line|
        next unless (method = method_from_nm_line(line))

        methods << method
      end
    end
    methods
  end
end
list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables) click to toggle source

rubocop:enable Metrics/CyclomaticComplexity

# File lib/xcknife/test_dumper.rb, line 245
def list_tests_with_simctl(list_folder, test_bundle, test_bundle_name, extra_environment_variables)
  env_variables = test_bundle['EnvironmentVariables']
  testing_env_variables = test_bundle['TestingEnvironmentVariables']
  outpath = File.join(list_folder, test_bundle_name)
  test_host = replace_vars(test_bundle['TestHostPath'])
  test_bundle_path = replace_vars(test_bundle['TestBundlePath'], test_host)
  test_dumper_path = File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'TestDumper', 'TestDumper.dylib'))
  raise TestDumpError, "Could not find TestDumper.dylib on #{test_dumper_path}" unless File.exist?(test_dumper_path)

  is_logic_test = test_bundle['TestHostBundleIdentifier'].nil?
  env = simctl_child_attrs(
    'XCTEST_TYPE' => xctest_type(test_bundle),
    'XCTEST_TARGET' => test_bundle_name,
    'TestDumperOutputPath' => outpath,
    'IDE_INJECTION_PATH' => testing_env_variables['DYLD_INSERT_LIBRARIES'],
    'XCInjectBundleInto' => testing_env_variables['XCInjectBundleInto'],
    'XCInjectBundle' => test_bundle_path,
    'TestBundleLocation' => test_bundle_path,
    'OS_ACTIVITY_MODE' => 'disable',
    'DYLD_PRINT_LIBRARIES' => 'YES',
    'DYLD_PRINT_ENV' => 'YES',
    'DYLD_ROOT_PATH' => @sdk_path,
    'DYLD_LIBRARY_PATH' => env_variables['DYLD_LIBRARY_PATH'],
    'DYLD_FRAMEWORK_PATH' => env_variables['DYLD_FRAMEWORK_PATH'],
    'DYLD_FALLBACK_LIBRARY_PATH' => "#{@sdk_path}/usr/lib",
    'DYLD_FALLBACK_FRAMEWORK_PATH' => "#{@platform_path}/Developer/Library/Frameworks",
    'DYLD_INSERT_LIBRARIES' => test_dumper_path
  )
  env.merge!(simctl_child_attrs(extra_environment_variables))
  inject_vars(env, test_host)
  FileUtils.rm_f(outpath)
  logger.info { "Temporary TestDumper file for #{test_bundle_name} is #{outpath}" }
  if is_logic_test
    run_logic_test(env, test_host, test_bundle_path)
  else
    install_app(test_host)
    test_host_bundle_identifier = replace_vars(test_bundle['TestHostBundleIdentifier'], test_host)
    run_apptest(env, test_host_bundle_identifier, test_bundle_path)
  end
  TestSpecification.new outpath, discover_tests_to_skip(test_bundle)
end
method_from_nm_line(line) click to toggle source
# File lib/xcknife/test_dumper.rb, line 448
def method_from_nm_line(line)
  return unless line.strip =~ /^
    [\da-f]+\s # address
    [tT]\s # symbol type
    (?: # method
      -\[(.+)\s(test.+)\] # objc instance method
      | # or swift instance method
        _? # only present on Xcode 10.0 and below
        (?:@objc\s)? # optional objc annotation
        (?:[^. ]+\.)? # module name
        ([^ ]+) # class name
        \.(test.+)\s->\s\(\) # method signature
    )
  $/ox

  { class: Regexp.last_match(1) || Regexp.last_match(3), method: Regexp.last_match(2) || Regexp.last_match(4) }
end
output_methods(list_folder, test_bundle, test_bundle_name) { |test_bundle_path| ... } click to toggle source
# File lib/xcknife/test_dumper.rb, line 309
def output_methods(list_folder, test_bundle, test_bundle_name)
  outpath = File.join(list_folder, test_bundle_name)
  logger.info { "Writing out TestDumper file for #{test_bundle_name} to #{outpath}" }
  test_specification = TestSpecification.new outpath, discover_tests_to_skip(test_bundle)

  test_bundle_path = replace_vars(test_bundle['TestBundlePath'], replace_vars(test_bundle['TestHostPath']))
  methods = yield(test_bundle_path)

  test_type = xctest_type(test_bundle)
  File.open test_specification.json_stream_file, 'a' do |f|
    f << JSON.dump(message: 'Starting Test Dumper', event: 'begin-test-suite', testType: test_type) << "\n"
    f << JSON.dump(event: 'begin-ocunit', bundleName: File.basename(test_bundle_path), targetName: test_bundle_name) << "\n"
    methods.map { |method| method[:class] }.uniq.each do |class_name|
      f << JSON.dump(test: '1', className: class_name, event: 'end-test', totalDuration: '0') << "\n"
    end
    f << JSON.dump(message: 'Completed Test Dumper', event: 'end-action', testType: test_type) << "\n"
  end

  test_specification
end
replace_vars(str, testhost = '<UNKNOWN>') click to toggle source
# File lib/xcknife/test_dumper.rb, line 360
def replace_vars(str, testhost = '<UNKNOWN>')
  str.gsub('__PLATFORMS__', @platforms_path)
     .gsub('__TESTHOST__', testhost)
     .gsub('__TESTROOT__', testroot)
end
run_apptest(env, test_host_bundle_identifier, test_bundle_path) click to toggle source
# File lib/xcknife/test_dumper.rb, line 408
def run_apptest(env, test_host_bundle_identifier, test_bundle_path)
  return if call_simctl(['launch', @device_id, test_host_bundle_identifier, '-XCTest', 'All', dylib_logfile_path, test_bundle_path], env: env)

  raise TestDumpError, "Launching #{test_bundle_path} in #{test_host_bundle_identifier} failed"
end
run_logic_test(env, test_host, test_bundle_path) click to toggle source
# File lib/xcknife/test_dumper.rb, line 414
def run_logic_test(env, test_host, test_bundle_path)
  opts = @debug ? {} : { err: '/dev/null' }
  return if call_simctl(['spawn', @device_id, test_host, '-XCTest', 'All', dylib_logfile_path, test_bundle_path], env: env, **opts)

  raise TestDumpError, "Spawning #{test_bundle_path} in #{test_host} failed"
end
simctl() click to toggle source
# File lib/xcknife/test_dumper.rb, line 336
def simctl
  @simctl_path
end
simctl_child_attrs(attrs = {}) click to toggle source
# File lib/xcknife/test_dumper.rb, line 372
def simctl_child_attrs(attrs = {})
  env = {}
  attrs.each { |k, v| env["SIMCTL_CHILD_#{k}"] = v }
  env
end
swift_demangled_nm(test_bundle_path, &block) click to toggle source
# File lib/xcknife/test_dumper.rb, line 444
def swift_demangled_nm(test_bundle_path, &block)
  Open3.pipeline_r([@nm_path, File.join(test_bundle_path, File.basename(test_bundle_path, '.xctest'))], [@swift_path, 'demangle'], &block)
end
test_dumper_terminated?(file) click to toggle source
# File lib/xcknife/test_dumper.rb, line 401
def test_dumper_terminated?(file)
  return false unless File.exist?(file)

  last_line = `tail -n 1 "#{file}"`
  last_line.include?('Completed Test Dumper')
end
test_groups(xctestrun) click to toggle source
# File lib/xcknife/test_dumper.rb, line 185
def test_groups(xctestrun)
  xctestrun.group_by do |test_bundle_name, _test_bundle|
    if @skip_dump_bundle_names.include?(test_bundle_name)
      'single'
    elsif @naive_dump_bundle_names.include?(test_bundle_name)
      'nm'
    else
      'simctl'
    end
  end
end
wait_test_dumper_completion(file) click to toggle source
# File lib/xcknife/test_dumper.rb, line 391
def wait_test_dumper_completion(file)
  retries_count = 0
  until test_dumper_terminated?(file)
    retries_count += 1
    raise TestDumpError, "Timeout error on: #{file}" if retries_count == @max_retry_count

    sleep 0.1
  end
end
wrapped_simctl(args) click to toggle source
# File lib/xcknife/test_dumper.rb, line 340
def wrapped_simctl(args)
  [*gtimeout, simctl] + args
end
xctest_type(test_bundle) click to toggle source
# File lib/xcknife/test_dumper.rb, line 436
def xctest_type(test_bundle)
  if test_bundle['TestHostBundleIdentifier'].nil?
    'LOGICTEST'
  else
    'APPTEST'
  end
end