Files
yattee/spec/ui/support/axe.rb
Arkadiusz Fal a4b0c60a3e Run AXe against an Xcode that ships SimulatorKit
AXe dlopens the private SimulatorKit.framework from
$DEVELOPER_DIR/Library/PrivateFrameworks. Xcode 27 relocated that
framework to Contents/SharedFrameworks, so under a selected Xcode 27
every axe command fails with "Failed to load essential private
frameworks" and all UI assertions fail uniformly.

Detect when the active Xcode lacks SimulatorKit at the path AXe expects
and inject DEVELOPER_DIR pointing at the first installed Xcode that has
it, for every axe subprocess. No global xcode-select switch required.

Claude-Session: https://claude.ai/code/session_01Aqq5bSGrYfjQNvkTEzWcva
2026-06-30 09:35:32 +02:00

310 lines
9.7 KiB
Ruby

# frozen_string_literal: true
require 'open3'
require 'json'
require 'fileutils'
module UITest
# Wrapper for AXe CLI tool for iOS Simulator automation
class Axe
class AxeError < StandardError; end
attr_reader :udid
def initialize(udid)
@udid = udid
end
# Get the full accessibility UI tree as parsed JSON
# @return [Hash] Parsed accessibility tree
def describe_ui
output, status = run_axe('describe-ui')
raise AxeError, "describe-ui failed: #{output}" unless status.success?
JSON.parse(output)
rescue JSON::ParserError => e
raise AxeError, "Failed to parse accessibility tree: #{e.message}"
end
# Check if an element with the given accessibility identifier exists
# @param identifier [String] Accessibility identifier to find
# @return [Boolean] true if element exists
def element_exists?(identifier)
tree = describe_ui
find_element_in_tree(tree, identifier: identifier).present?
rescue AxeError
false
end
# Find an element by accessibility identifier
# @param identifier [String] Accessibility identifier
# @return [Hash, nil] Element data or nil if not found
def find_element(identifier)
tree = describe_ui
find_element_in_tree(tree, identifier: identifier)
end
# Check if text is visible anywhere in the accessibility tree
# @param text [String] Text to search for
# @return [Boolean] true if text is visible
def text_visible?(text)
tree = describe_ui
find_element_in_tree(tree, label: text).present?
rescue AxeError
false
end
# Whether any element in the given tree has an AXUniqueId starting with the
# given prefix. Pass an already-fetched tree to avoid re-spawning `axe`.
def self.id_with_prefix_in_tree?(node, prefix)
case node
when Hash
return true if node['AXUniqueId']&.start_with?(prefix)
node.each_value do |value|
return true if id_with_prefix_in_tree?(value, prefix)
end
when Array
node.each do |item|
return true if id_with_prefix_in_tree?(item, prefix)
end
end
false
end
# Whether any element in the given tree has an AXLabel containing `text`.
# Pass an already-fetched tree to avoid re-spawning `axe`.
def self.label_in_tree?(node, text)
case node
when Hash
return true if node['AXLabel']&.include?(text)
node.each_value do |value|
return true if label_in_tree?(value, text)
end
when Array
node.each do |item|
return true if label_in_tree?(item, text)
end
end
false
end
# Whether any element in the given tree has the given AXUniqueId. Pass an
# already-fetched tree to avoid re-spawning `axe`.
def self.id_in_tree?(node, identifier)
case node
when Hash
return true if node['AXUniqueId'] == identifier
node.each_value do |value|
return true if id_in_tree?(value, identifier)
end
when Array
node.each do |item|
return true if id_in_tree?(item, identifier)
end
end
false
end
# Tap on an element by accessibility identifier
# @param identifier [String] Accessibility identifier
def tap_id(identifier)
output, status = run_axe('tap', '--id', identifier)
raise AxeError, "tap failed: #{output}" unless status.success?
end
# Tap on an element by accessibility label
# @param label [String] Accessibility label
def tap_label(label)
output, status = run_axe('tap', '--label', label)
raise AxeError, "tap failed: #{output}" unless status.success?
end
# Tap at specific coordinates
# @param x [Integer] X coordinate
# @param y [Integer] Y coordinate
def tap_coordinates(x:, y:)
output, status = run_axe('tap', '-x', x.to_s, '-y', y.to_s)
raise AxeError, "tap failed: #{output}" unless status.success?
end
# Perform a swipe gesture
# @param start_x [Integer] Starting X coordinate
# @param start_y [Integer] Starting Y coordinate
# @param end_x [Integer] Ending X coordinate
# @param end_y [Integer] Ending Y coordinate
# @param duration [Float] Duration in seconds (optional)
def swipe(start_x:, start_y:, end_x:, end_y:, duration: nil)
args = ['swipe', '--start-x', start_x.to_s, '--start-y', start_y.to_s,
'--end-x', end_x.to_s, '--end-y', end_y.to_s]
args += ['--duration', duration.to_s] if duration
output, status = run_axe(*args)
raise AxeError, "swipe failed: #{output}" unless status.success?
end
# Perform a preset gesture
# @param preset [String] Gesture preset (scroll-up, scroll-down, etc.)
def gesture(preset)
output, status = run_axe('gesture', preset)
raise AxeError, "gesture failed: #{output}" unless status.success?
end
# Type text
# @param text [String] Text to type
def type(text)
output, status = Open3.capture2e(self.class.axe_env, 'axe', 'type', '--stdin', '--udid', @udid, stdin_data: text)
output = output.dup.force_encoding('UTF-8') if output.is_a?(String)
raise AxeError, "type failed: #{output}" unless status.success?
end
# Press the home button
def home_button
output, status = run_axe('button', 'home')
raise AxeError, "home button failed: #{output}" unless status.success?
end
# Press a key by keycode
# @param keycode [Integer] HID keycode (e.g., 40 for Return/Enter)
def press_key(keycode)
output, status = run_axe('key', keycode.to_s)
raise AxeError, "key press failed: #{output}" unless status.success?
end
# Press Return/Enter key
def press_return
press_key(40)
end
# Press Escape key
def press_escape
press_key(41)
end
# Take a screenshot and save it
# @param name [String] Screenshot name (without extension)
# @return [String] Path to the saved screenshot
def screenshot(name)
Config.ensure_directories!
path = File.join(Config.current_dir, "#{name}.png")
output, status = run_axe('screenshot', '--output', path)
raise AxeError, "screenshot failed: #{output}" unless status.success?
# Wait for file to be fully written to disk
wait_for_file(path)
path
end
private
def run_axe(*)
output, status = Open3.capture2e(self.class.axe_env, 'axe', *, '--udid', @udid)
output = output.dup.force_encoding('UTF-8') if output.is_a?(String)
[output, status]
end
class << self
# Environment overrides for every `axe` invocation.
#
# AXe loads Xcode's private SimulatorKit.framework at runtime. Some Xcode
# betas ship without it (e.g. Xcode 27 beta), which makes every `axe`
# command fail with "Failed to load essential private frameworks". When
# the active Xcode is missing SimulatorKit, fall back to any installed
# Xcode that has it by overriding DEVELOPER_DIR for the axe subprocess.
# @return [Hash] Environment hash to pass to Open3
def axe_env
return @axe_env if defined?(@axe_env)
@axe_env = simulatorkit?(active_developer_dir) ? {} : fallback_env
end
private
def fallback_env
dir = usable_developer_dir
dir ? { 'DEVELOPER_DIR' => dir } : {}
end
# Path to the active Xcode's Developer directory (xcode-select -p)
def active_developer_dir
@active_developer_dir ||= `xcode-select -p 2>/dev/null`.strip
end
# First installed Xcode Developer directory that ships SimulatorKit
def usable_developer_dir
Dir.glob('/Applications/Xcode*.app/Contents/Developer').find do |dir|
simulatorkit?(dir)
end
end
# Whether a Developer directory ships the SimulatorKit private framework
def simulatorkit?(developer_dir)
return false if developer_dir.nil? || developer_dir.empty?
File.exist?(File.join(developer_dir, 'Library', 'PrivateFrameworks', 'SimulatorKit.framework'))
end
end
# Wait for a file to exist and have non-zero size
# Helps avoid race conditions where screenshot isn't fully written
# @param path [String] Path to the file
# @param timeout [Float] Maximum time to wait in seconds
def wait_for_file(path, timeout: 2.0)
start_time = Time.now
loop do
return if File.exist?(path) && File.size(path) > 100
break if Time.now - start_time > timeout
sleep 0.1
end
end
# Recursively search the accessibility tree for an element
# @param node [Hash, Array] Current node in the tree
# @param identifier [String, nil] Accessibility identifier to match (AXUniqueId)
# @param label [String, nil] Accessibility label to match (AXLabel)
# @return [Hash, nil] Found element or nil
def find_element_in_tree(node, identifier: nil, label: nil)
case node
when Hash
# Check if this node matches by identifier (AXUniqueId in AXe output)
return node if identifier && node['AXUniqueId'] == identifier
# Check if this node matches by label (AXLabel in AXe output)
return node if label && node['AXLabel']&.include?(label)
# Recursively search children
node.each_value do |value|
result = find_element_in_tree(value, identifier: identifier, label: label)
return result if result
end
when Array
node.each do |item|
result = find_element_in_tree(item, identifier: identifier, label: label)
return result if result
end
end
nil
end
end
end
# Add present? method for nil/empty checking
class Object
def present?
respond_to?(:empty?) ? !empty? : !nil?
end
end
class NilClass
def present?
false
end
end