Files
yattee/spec/ui/support/axe.rb
Arkadiusz Fal c778ca5d06 Fix flaky integration tests and UI test runner robustness
- Skip Invidious integration tests gracefully on .noConnection so a
  transient instance outage no longer fails CI
- Point integration tests at i01.v.yattee.stream (the previous test
  instance was decommissioned)
- Force UTF-8 on AXe CLI output in the UI test wrapper; ASCII-tagged
  bytes were crashing JSON.parse in describe_ui
- Add iOS 26.4 visual baselines for app-launch-home and settings-main
2026-05-10 15:28:11 +02:00

268 lines
8.2 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('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('axe', *, '--udid', @udid)
output = output.dup.force_encoding('UTF-8') if output.is_a?(String)
[output, status]
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