mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 05:42:04 +00:00
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
310 lines
9.7 KiB
Ruby
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
|