mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
Three end-to-end specs that exercise the new basic-auth flows against a
real Invidious instance fronted by an nginx reverse proxy:
1. add flow: types the URL, hits Detect, fills the basic-auth fields
when the basicAuthRequired UI state appears, taps Retry Detection,
and confirms the instance lands in the Sources list.
2. state assertion: types a URL, taps Detect, and verifies the form
transitions into the basicAuthRequired state (Retry Detection button
present, no detected type) when no credentials were supplied.
3. proxied login: ensures the instance exists, then drives the standard
Invidious login flow with the proxied account credentials. Confirms
the SID Cookie auth coexists with the per-client Authorization
header on the basic-auth-aware HTTPClient.
Test infrastructure additions:
- spec/ui/support/config.rb: env-driven accessors for the basic-auth URL
and proxied-account credentials. No secrets committed.
- spec/ui/support/instance_setup.rb: helpers
add_invidious_with_basic_auth, remove_and_add_invidious_with_basic_auth,
find_basic_auth_text_fields (mirroring find_auth_text_fields), and
fill_field for tapping a discovered field by frame and typing into it.
All three specs skip cleanly when the relevant env vars are not set.
1213 lines
38 KiB
Ruby
1213 lines
38 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'uri'
|
|
|
|
module UITest
|
|
# Helper for setting up instances in UI tests.
|
|
# Provides methods to navigate through Settings and add/verify instances.
|
|
class InstanceSetup
|
|
# Coordinates for iPhone 17 Pro (393pt width)
|
|
# Settings gear button in Home toolbar (top-right)
|
|
SETTINGS_BUTTON_COORDS = { x: 380, y: 70 }.freeze
|
|
|
|
def initialize(axe)
|
|
@axe = axe
|
|
end
|
|
|
|
# Check if Yattee Server instance exists by navigating to Sources
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if instance exists
|
|
def yattee_server_exists?(host)
|
|
navigate_to_sources
|
|
exists = @axe.text_visible?(host)
|
|
close_settings
|
|
exists
|
|
end
|
|
|
|
# Add a Yattee Server instance via Detect & Add flow
|
|
# @param url [String] Full URL of the Yattee Server
|
|
def add_yattee_server(url)
|
|
navigate_to_sources
|
|
|
|
# Tap Add Source button (toolbar or empty state)
|
|
tap_add_source_button
|
|
sleep 0.8
|
|
|
|
# Select Remote Server from the source type list
|
|
select_remote_server_tab
|
|
|
|
# Wait for URL field to appear
|
|
wait_for_element('addRemoteServer.urlField')
|
|
|
|
# Enter URL in text field
|
|
@axe.tap_id('addRemoteServer.urlField')
|
|
sleep 0.5
|
|
@axe.type(url)
|
|
sleep 0.5
|
|
|
|
# Tap Detect button to identify server type
|
|
@axe.tap_id('addRemoteServer.detectButton')
|
|
sleep 0.5
|
|
|
|
# Wait for detection to complete
|
|
result = wait_for_detection(timeout: 20)
|
|
raise "Detection failed: #{result}" if result == :error
|
|
|
|
# Enter Yattee Server credentials if available
|
|
username = Config.yattee_server_username
|
|
password = Config.yattee_server_password
|
|
raise 'Yattee Server credentials not configured (set YATTEE_SERVER_USERNAME and YATTEE_SERVER_PASSWORD)' unless username && password
|
|
|
|
sleep 0.5
|
|
|
|
# Find and fill credential fields by locating text fields after the "Authentication" header
|
|
auth_fields = find_auth_text_fields
|
|
raise 'Could not find username/password fields' if auth_fields.length < 2
|
|
|
|
# First field is username, second is password
|
|
username_frame = auth_fields[0]['frame']
|
|
password_frame = auth_fields[1]['frame']
|
|
|
|
# Tap and fill username
|
|
@axe.tap_coordinates(
|
|
x: username_frame['x'] + (username_frame['width'] / 2),
|
|
y: username_frame['y'] + (username_frame['height'] / 2)
|
|
)
|
|
sleep 0.3
|
|
@axe.type(username)
|
|
sleep 0.3
|
|
|
|
# Tap and fill password
|
|
@axe.tap_coordinates(
|
|
x: password_frame['x'] + (password_frame['width'] / 2),
|
|
y: password_frame['y'] + (password_frame['height'] / 2)
|
|
)
|
|
sleep 0.3
|
|
@axe.type(password)
|
|
sleep 0.3
|
|
|
|
# Wait for action button and tap it
|
|
wait_for_element('addRemoteServer.actionButton')
|
|
@axe.tap_id('addRemoteServer.actionButton')
|
|
sleep 0.5
|
|
|
|
# Wait for credential validation and sheet dismiss
|
|
wait_for_add_complete(timeout: 20)
|
|
|
|
# Close Settings (return to Home)
|
|
close_settings
|
|
end
|
|
|
|
# Ensure Yattee Server instance exists (idempotent)
|
|
# @param url [String] Full URL of the Yattee Server
|
|
# @return [Boolean] true if instance was added, false if already existed
|
|
def ensure_yattee_server(url)
|
|
host = URI.parse(url).host
|
|
|
|
if yattee_server_exists?(host)
|
|
puts " Yattee Server instance already exists: #{host}"
|
|
return false
|
|
end
|
|
|
|
puts " Adding Yattee Server instance: #{url}"
|
|
add_yattee_server(url)
|
|
true
|
|
end
|
|
|
|
# Remove Yattee Server instance if it exists, then add it fresh
|
|
# @param url [String] Full URL of the Yattee Server
|
|
def remove_and_add_yattee_server(url)
|
|
host = URI.parse(url).host
|
|
|
|
if yattee_server_exists?(host)
|
|
puts " Removing existing Yattee Server instance: #{host}"
|
|
remove_yattee_server(host)
|
|
end
|
|
|
|
puts " Adding Yattee Server instance: #{url}"
|
|
add_yattee_server(url)
|
|
end
|
|
|
|
# Remove a Yattee Server instance by host
|
|
# @param host [String] Host portion of the server URL
|
|
def remove_yattee_server(host)
|
|
remove_instance("sources.row.yatteeServer.#{host}", host)
|
|
end
|
|
|
|
# Check if Invidious instance exists by navigating to Sources
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if instance exists
|
|
def invidious_exists?(host)
|
|
navigate_to_sources
|
|
exists = @axe.text_visible?(host)
|
|
close_settings
|
|
exists
|
|
end
|
|
|
|
# Add an Invidious instance via Detect & Add flow
|
|
# @param url [String] Full URL of the Invidious instance
|
|
def add_invidious(url)
|
|
add_instance(url)
|
|
end
|
|
|
|
# Ensure Invidious instance exists (idempotent)
|
|
# @param url [String] Full URL of the Invidious instance
|
|
# @return [Boolean] true if instance was added, false if already existed
|
|
def ensure_invidious(url)
|
|
host = URI.parse(url).host
|
|
|
|
if invidious_exists?(host)
|
|
puts " Invidious instance already exists: #{host}"
|
|
return false
|
|
end
|
|
|
|
puts " Adding Invidious instance: #{url}"
|
|
add_invidious(url)
|
|
true
|
|
end
|
|
|
|
# Remove Invidious instance if it exists, then add it fresh
|
|
# @param url [String] Full URL of the Invidious instance
|
|
def remove_and_add_invidious(url)
|
|
host = URI.parse(url).host
|
|
|
|
if invidious_exists?(host)
|
|
puts " Removing existing Invidious instance: #{host}"
|
|
remove_invidious(host)
|
|
end
|
|
|
|
puts " Adding Invidious instance: #{url}"
|
|
add_invidious(url)
|
|
end
|
|
|
|
# Remove an Invidious instance by host
|
|
# @param host [String] Host portion of the server URL
|
|
def remove_invidious(host)
|
|
remove_instance("sources.row.invidious.#{host}", host)
|
|
end
|
|
|
|
# Add an Invidious instance whose detection is fronted by HTTP Basic Auth.
|
|
# Drives the new RemoteServerUIState.basicAuthRequired path: type the URL,
|
|
# tap Detect, fill the basic-auth fields when the Retry Detection button
|
|
# appears, retry, then tap Add Source.
|
|
# @param url [String] Full URL of the Invidious instance
|
|
# @param username [String] HTTP Basic Auth username
|
|
# @param password [String] HTTP Basic Auth password
|
|
def add_invidious_with_basic_auth(url, username:, password:)
|
|
navigate_to_sources
|
|
|
|
tap_add_source_button
|
|
sleep 0.8
|
|
|
|
select_remote_server_tab
|
|
|
|
wait_for_element('addRemoteServer.urlField')
|
|
|
|
@axe.tap_id('addRemoteServer.urlField')
|
|
sleep 0.5
|
|
@axe.type(url)
|
|
sleep 0.5
|
|
|
|
@axe.tap_id('addRemoteServer.detectButton')
|
|
|
|
# First detection should fail with .basicAuthRequired — the Retry Detection
|
|
# button appears together with the username/password fields.
|
|
wait_for_element('addRemoteServer.retryDetectionButton', timeout: 20)
|
|
|
|
sleep 0.3
|
|
|
|
fields = find_basic_auth_text_fields
|
|
raise 'Could not find basic-auth username/password fields' if fields.length < 2
|
|
|
|
fill_field(fields[0], username)
|
|
fill_field(fields[1], password)
|
|
|
|
@axe.tap_id('addRemoteServer.retryDetectionButton')
|
|
sleep 0.5
|
|
|
|
# After a successful retry, detection should reveal the standard form.
|
|
result = wait_for_detection(timeout: 20)
|
|
raise "Detection retry failed: #{result}" if result == :error
|
|
|
|
wait_for_element('addRemoteServer.actionButton')
|
|
@axe.tap_id('addRemoteServer.actionButton')
|
|
sleep 0.5
|
|
|
|
wait_for_add_complete(timeout: 20)
|
|
close_settings
|
|
end
|
|
|
|
# Remove the basic-auth Invidious instance if it exists, then add it fresh.
|
|
# @param url [String] Full URL of the Invidious instance
|
|
# @param username [String] HTTP Basic Auth username
|
|
# @param password [String] HTTP Basic Auth password
|
|
def remove_and_add_invidious_with_basic_auth(url, username:, password:)
|
|
host = URI.parse(url).host
|
|
|
|
if invidious_exists?(host)
|
|
puts " Removing existing Invidious instance: #{host}"
|
|
remove_invidious(host)
|
|
end
|
|
|
|
puts " Adding Invidious instance behind basic auth: #{url}"
|
|
add_invidious_with_basic_auth(url, username: username, password: password)
|
|
end
|
|
|
|
# Check if logged in to Invidious instance
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if logged in
|
|
def invidious_logged_in?(host)
|
|
navigate_to_sources
|
|
|
|
# Tap on Invidious instance row to open EditSourceView
|
|
# Due to iOS 26 accessibility issues, use coordinates from the element
|
|
tap_element_containing_text(host)
|
|
sleep 0.8
|
|
|
|
# Check if "Log Out" is visible (indicates logged in)
|
|
logged_in = @axe.text_visible?('Log Out')
|
|
|
|
# Close the edit sheet
|
|
close_edit_sheet
|
|
|
|
logged_in
|
|
end
|
|
|
|
# Log in to Invidious instance
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if login succeeded
|
|
def login_invidious(host)
|
|
email = Config.invidious_email
|
|
password = Config.invidious_password
|
|
|
|
raise 'Invidious credentials not configured (set INVIDIOUS_EMAIL and INVIDIOUS_PASSWORD)' unless email && password
|
|
|
|
navigate_to_sources
|
|
|
|
# Tap on Invidious instance row to open EditSourceView
|
|
# Due to iOS 26 accessibility issues, use coordinates from the element
|
|
tap_element_containing_text(host)
|
|
sleep 0.8
|
|
|
|
# Wait for EditSourceView
|
|
wait_for_element('editSource.view')
|
|
|
|
# Tap Log in button
|
|
@axe.tap_label('Log in to Account')
|
|
sleep 0.8
|
|
|
|
# Wait for login sheet
|
|
wait_for_element('instance.login.view')
|
|
|
|
# Enter email/username
|
|
@axe.tap_id('instance.login.usernameField')
|
|
sleep 0.3
|
|
@axe.type(email)
|
|
sleep 0.3
|
|
|
|
# Enter password
|
|
@axe.tap_id('instance.login.passwordField')
|
|
sleep 0.3
|
|
@axe.type(password)
|
|
sleep 0.3
|
|
|
|
# Tap Sign In button
|
|
@axe.tap_id('instance.login.submitButton')
|
|
|
|
# Wait for login to complete (login sheet dismisses, back to edit view)
|
|
start_time = Time.now
|
|
dismiss_attempts = 0
|
|
loop do
|
|
elapsed = (Time.now - start_time).round(1)
|
|
|
|
# Check if login succeeded (logout button visible)
|
|
if @axe.text_visible?('Log Out')
|
|
puts " [#{elapsed}s] Found Log Out button"
|
|
break
|
|
end
|
|
|
|
# Check for error
|
|
if @axe.element_exists?('instance.login.error')
|
|
puts ' Login failed with error'
|
|
close_edit_sheet
|
|
return false
|
|
end
|
|
|
|
# The iOS "Save Password?" dialog is a system dialog that blocks the accessibility tree
|
|
# When it appears, the app's children become empty or show different content
|
|
tree = @axe.describe_ui
|
|
app_children = tree.is_a?(Array) ? tree.first&.dig('children') : nil
|
|
app_has_no_children = app_children.nil? || app_children.empty?
|
|
|
|
# Also check if we're stuck (not on login view, not on edit view with Log Out)
|
|
has_login_view = find_first_element_with_id(tree, 'instance.login.view')
|
|
|
|
# Detect password dialog: either empty children OR we're past the login view but don't see Log Out
|
|
password_dialog_likely = app_has_no_children || (!has_login_view && !@axe.text_visible?('Log Out') && elapsed > 1.5)
|
|
|
|
if password_dialog_likely && elapsed > 1.0 && dismiss_attempts < 20
|
|
puts " [#{elapsed}s] Password dialog likely blocking, attempting dismiss ##{dismiss_attempts + 1}..."
|
|
# Try different approaches to dismiss the password save dialog
|
|
# The iOS "Save Password?" dialog appears at bottom of screen
|
|
# "Not Now" button is typically on the left side of the dialog
|
|
case dismiss_attempts
|
|
when 0
|
|
# Try "Not Now" button - bottom left area for iPhone 17 Pro (393pt width, 852pt height)
|
|
@axe.tap_coordinates(x: 100, y: 750)
|
|
when 1
|
|
# Slightly higher
|
|
@axe.tap_coordinates(x: 100, y: 720)
|
|
when 2
|
|
# Slightly to the right
|
|
@axe.tap_coordinates(x: 130, y: 735)
|
|
when 3
|
|
# Try more to the left
|
|
@axe.tap_coordinates(x: 80, y: 740)
|
|
when 4
|
|
# Try different vertical position
|
|
@axe.tap_coordinates(x: 100, y: 700)
|
|
when 5
|
|
# Try center-left
|
|
@axe.tap_coordinates(x: 120, y: 710)
|
|
when 6
|
|
# Try tapping outside the dialog area
|
|
@axe.tap_coordinates(x: 200, y: 100)
|
|
when 7
|
|
# Try swipe down to dismiss
|
|
@axe.swipe(start_x: 200, start_y: 600, end_x: 200, end_y: 800, duration: 0.3)
|
|
when 8
|
|
# Try Return key which might select default
|
|
@axe.press_key(40)
|
|
when 9
|
|
# Try more coordinates
|
|
@axe.tap_coordinates(x: 90, y: 730)
|
|
when 10
|
|
# Upper part of dialog
|
|
@axe.tap_coordinates(x: 100, y: 680)
|
|
when 11
|
|
# Try space key
|
|
@axe.press_key(44)
|
|
when 12
|
|
# More attempts at common positions
|
|
@axe.tap_coordinates(x: 110, y: 725)
|
|
when 13
|
|
# Tab key to move focus, then enter
|
|
@axe.press_key(43)
|
|
sleep 0.2
|
|
@axe.press_key(40)
|
|
when 14
|
|
# Try ESC key
|
|
@axe.press_key(41)
|
|
when 15
|
|
# Try coordinates for larger dialog variant
|
|
@axe.tap_coordinates(x: 100, y: 780)
|
|
when 16
|
|
# Try far left
|
|
@axe.tap_coordinates(x: 50, y: 740)
|
|
when 17
|
|
# Try middle of screen
|
|
@axe.tap_coordinates(x: 200, y: 740)
|
|
when 18
|
|
# Swipe up
|
|
@axe.swipe(start_x: 200, start_y: 750, end_x: 200, end_y: 400, duration: 0.3)
|
|
when 19
|
|
# Final ESC attempt
|
|
@axe.press_key(41)
|
|
end
|
|
dismiss_attempts += 1
|
|
sleep 0.5
|
|
next
|
|
end
|
|
|
|
if Time.now - start_time > 35
|
|
# Dump UI tree for debugging
|
|
puts ' Login timed out - dumping UI tree:'
|
|
puts tree.to_s[0..3000]
|
|
raise 'Login timed out'
|
|
end
|
|
|
|
sleep 0.5
|
|
end
|
|
|
|
puts ' Login succeeded'
|
|
|
|
# Close edit sheet and return to Home
|
|
# After successful login, we're on EditSourceView - need to go back to Sources
|
|
# Try Back button first (for navigation-based sheets)
|
|
begin
|
|
@axe.tap_label('Back')
|
|
sleep 0.5
|
|
rescue UITest::Axe::AxeError
|
|
# Try swipe to go back (edge swipe from left)
|
|
@axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
|
|
sleep 0.5
|
|
end
|
|
|
|
# Now close the Settings sheet
|
|
close_settings
|
|
sleep 0.5
|
|
|
|
# Verify we're back on Home, attempt recovery if not
|
|
unless @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
dismiss_any_sheets
|
|
sleep 0.5
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
# Ensure logged in to Invidious (idempotent)
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if login was performed, false if already logged in
|
|
def ensure_invidious_logged_in(host)
|
|
if invidious_logged_in?(host)
|
|
puts " Already logged in to Invidious: #{host}"
|
|
return false
|
|
end
|
|
|
|
puts " Logging in to Invidious: #{host}"
|
|
login_invidious(host)
|
|
true
|
|
end
|
|
|
|
# Check if Piped instance exists by navigating to Sources
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if instance exists
|
|
def piped_exists?(host)
|
|
navigate_to_sources
|
|
exists = @axe.text_visible?(host)
|
|
close_settings
|
|
exists
|
|
end
|
|
|
|
# Add a Piped instance via Detect & Add flow
|
|
# @param url [String] Full URL of the Piped instance
|
|
def add_piped(url)
|
|
add_instance(url)
|
|
end
|
|
|
|
# Ensure Piped instance exists (idempotent)
|
|
# @param url [String] Full URL of the Piped instance
|
|
# @return [Boolean] true if instance was added, false if already existed
|
|
def ensure_piped(url)
|
|
host = URI.parse(url).host
|
|
|
|
if piped_exists?(host)
|
|
puts " Piped instance already exists: #{host}"
|
|
return false
|
|
end
|
|
|
|
puts " Adding Piped instance: #{url}"
|
|
add_piped(url)
|
|
true
|
|
end
|
|
|
|
# Remove Piped instance if it exists, then add it fresh
|
|
# @param url [String] Full URL of the Piped instance
|
|
def remove_and_add_piped(url)
|
|
host = URI.parse(url).host
|
|
|
|
if piped_exists?(host)
|
|
puts " Removing existing Piped instance: #{host}"
|
|
remove_piped(host)
|
|
end
|
|
|
|
puts " Adding Piped instance: #{url}"
|
|
add_piped(url)
|
|
end
|
|
|
|
# Remove a Piped instance by host
|
|
# @param host [String] Host portion of the server URL
|
|
def remove_piped(host)
|
|
remove_instance("sources.row.piped.#{host}", host)
|
|
end
|
|
|
|
# Check if logged in to Piped instance
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if logged in
|
|
def piped_logged_in?(host)
|
|
navigate_to_sources
|
|
|
|
# Tap on Piped instance row to open EditSourceView
|
|
# Due to iOS 26 accessibility issues, use coordinates from the element
|
|
tap_element_containing_text(host)
|
|
sleep 0.8
|
|
|
|
# Check if "Log Out" is visible (indicates logged in)
|
|
logged_in = @axe.text_visible?('Log Out')
|
|
|
|
# Close the edit sheet
|
|
close_edit_sheet
|
|
|
|
logged_in
|
|
end
|
|
|
|
# Log in to Piped instance
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if login succeeded
|
|
def login_piped(host)
|
|
username = Config.piped_username
|
|
password = Config.piped_password
|
|
|
|
raise 'Piped credentials not configured (set PIPED_USERNAME and PIPED_PASSWORD)' unless username && password
|
|
|
|
navigate_to_sources
|
|
|
|
# Tap on Piped instance row to open EditSourceView
|
|
# Due to iOS 26 accessibility issues, use coordinates from the element
|
|
tap_element_containing_text(host)
|
|
sleep 0.8
|
|
|
|
# Wait for EditSourceView
|
|
wait_for_element('editSource.view')
|
|
|
|
# Tap Log in button
|
|
@axe.tap_label('Log in to Account')
|
|
sleep 0.8
|
|
|
|
# Wait for login sheet
|
|
wait_for_element('instance.login.view')
|
|
|
|
# Enter username (Piped uses username, not email)
|
|
@axe.tap_id('instance.login.usernameField')
|
|
sleep 0.3
|
|
@axe.type(username)
|
|
sleep 0.3
|
|
|
|
# Enter password
|
|
@axe.tap_id('instance.login.passwordField')
|
|
sleep 0.3
|
|
@axe.type(password)
|
|
sleep 0.3
|
|
|
|
# Tap Sign In button
|
|
@axe.tap_id('instance.login.submitButton')
|
|
|
|
# Wait for login to complete (login sheet dismisses, back to edit view)
|
|
start_time = Time.now
|
|
dismiss_attempts = 0
|
|
loop do
|
|
elapsed = (Time.now - start_time).round(1)
|
|
|
|
# Check if login succeeded (logout button visible)
|
|
if @axe.text_visible?('Log Out')
|
|
puts " [#{elapsed}s] Found Log Out button"
|
|
break
|
|
end
|
|
|
|
# Check for error
|
|
if @axe.element_exists?('instance.login.error')
|
|
puts ' Login failed with error'
|
|
close_edit_sheet
|
|
return false
|
|
end
|
|
|
|
# The iOS "Save Password?" dialog is a system dialog that blocks the accessibility tree
|
|
# When it appears, the app's children become empty or show different content
|
|
tree = @axe.describe_ui
|
|
app_children = tree.is_a?(Array) ? tree.first&.dig('children') : nil
|
|
app_has_no_children = app_children.nil? || app_children.empty?
|
|
|
|
# Also check if we're stuck (not on login view, not on edit view with Log Out)
|
|
has_login_view = find_first_element_with_id(tree, 'instance.login.view')
|
|
|
|
# Detect password dialog: either empty children OR we're past the login view but don't see Log Out
|
|
password_dialog_likely = app_has_no_children || (!has_login_view && !@axe.text_visible?('Log Out') && elapsed > 1.5)
|
|
|
|
if password_dialog_likely && elapsed > 1.0 && dismiss_attempts < 20
|
|
puts " [#{elapsed}s] Password dialog likely blocking, attempting dismiss ##{dismiss_attempts + 1}..."
|
|
# Try different approaches to dismiss the password save dialog
|
|
# The iOS "Save Password?" dialog appears at bottom of screen
|
|
# "Not Now" button is typically on the left side of the dialog
|
|
case dismiss_attempts
|
|
when 0
|
|
# Try "Not Now" button - bottom left area for iPhone 17 Pro (393pt width, 852pt height)
|
|
@axe.tap_coordinates(x: 100, y: 750)
|
|
when 1
|
|
# Slightly higher
|
|
@axe.tap_coordinates(x: 100, y: 720)
|
|
when 2
|
|
# Slightly to the right
|
|
@axe.tap_coordinates(x: 130, y: 735)
|
|
when 3
|
|
# Try more to the left
|
|
@axe.tap_coordinates(x: 80, y: 740)
|
|
when 4
|
|
# Try different vertical position
|
|
@axe.tap_coordinates(x: 100, y: 700)
|
|
when 5
|
|
# Try center-left
|
|
@axe.tap_coordinates(x: 120, y: 710)
|
|
when 6
|
|
# Try tapping outside the dialog area
|
|
@axe.tap_coordinates(x: 200, y: 100)
|
|
when 7
|
|
# Try swipe down to dismiss
|
|
@axe.swipe(start_x: 200, start_y: 600, end_x: 200, end_y: 800, duration: 0.3)
|
|
when 8
|
|
# Try Return key which might select default
|
|
@axe.press_key(40)
|
|
when 9
|
|
# Try more coordinates
|
|
@axe.tap_coordinates(x: 90, y: 730)
|
|
when 10
|
|
# Upper part of dialog
|
|
@axe.tap_coordinates(x: 100, y: 680)
|
|
when 11
|
|
# Try space key
|
|
@axe.press_key(44)
|
|
when 12
|
|
# More attempts at common positions
|
|
@axe.tap_coordinates(x: 110, y: 725)
|
|
when 13
|
|
# Tab key to move focus, then enter
|
|
@axe.press_key(43)
|
|
sleep 0.2
|
|
@axe.press_key(40)
|
|
when 14
|
|
# Try ESC key
|
|
@axe.press_key(41)
|
|
when 15
|
|
# Try coordinates for larger dialog variant
|
|
@axe.tap_coordinates(x: 100, y: 780)
|
|
when 16
|
|
# Try far left
|
|
@axe.tap_coordinates(x: 50, y: 740)
|
|
when 17
|
|
# Try middle of screen
|
|
@axe.tap_coordinates(x: 200, y: 740)
|
|
when 18
|
|
# Swipe up
|
|
@axe.swipe(start_x: 200, start_y: 750, end_x: 200, end_y: 400, duration: 0.3)
|
|
when 19
|
|
# Final ESC attempt
|
|
@axe.press_key(41)
|
|
end
|
|
dismiss_attempts += 1
|
|
sleep 0.5
|
|
next
|
|
end
|
|
|
|
if Time.now - start_time > 35
|
|
# Dump UI tree for debugging
|
|
puts ' Login timed out - dumping UI tree:'
|
|
puts tree.to_s[0..3000]
|
|
raise 'Login timed out'
|
|
end
|
|
|
|
sleep 0.5
|
|
end
|
|
|
|
puts ' Login succeeded'
|
|
|
|
# Close edit sheet and return to Home
|
|
# After successful login, we're on EditSourceView - need to go back to Sources
|
|
# Try Back button first (for navigation-based sheets)
|
|
begin
|
|
@axe.tap_label('Back')
|
|
sleep 0.5
|
|
rescue UITest::Axe::AxeError
|
|
# Try swipe to go back (edge swipe from left)
|
|
@axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
|
|
sleep 0.5
|
|
end
|
|
|
|
# Now close the Settings sheet
|
|
close_settings
|
|
sleep 0.5
|
|
|
|
# Verify we're back on Home, attempt recovery if not
|
|
unless @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
dismiss_any_sheets
|
|
sleep 0.5
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
# Ensure logged in to Piped (idempotent)
|
|
# @param host [String] Host portion of the server URL
|
|
# @return [Boolean] true if login was performed, false if already logged in
|
|
def ensure_piped_logged_in(host)
|
|
if piped_logged_in?(host)
|
|
puts " Already logged in to Piped: #{host}"
|
|
return false
|
|
end
|
|
|
|
puts " Logging in to Piped: #{host}"
|
|
login_piped(host)
|
|
true
|
|
end
|
|
|
|
private
|
|
|
|
# Close edit source sheet and return to sources list
|
|
def close_edit_sheet
|
|
# Try Cancel button first
|
|
begin
|
|
@axe.tap_label('Cancel')
|
|
sleep 0.5
|
|
return if @axe.element_exists?('sources.view')
|
|
rescue UITest::Axe::AxeError
|
|
# Not found
|
|
end
|
|
|
|
# Try swipe down
|
|
@axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
|
|
sleep 0.5
|
|
end
|
|
|
|
# Tap the first element whose label contains the given text
|
|
# @param text [String] Text to search for in element labels
|
|
def tap_element_containing_text(text)
|
|
tree = @axe.describe_ui
|
|
element = find_element_with_label_text(tree, text)
|
|
raise UITest::Axe::AxeError, "No element found with text '#{text}'" unless element
|
|
|
|
frame = element['frame']
|
|
x = frame['x'] + (frame['width'] / 2)
|
|
y = frame['y'] + (frame['height'] / 2)
|
|
@axe.tap_coordinates(x: x, y: y)
|
|
end
|
|
|
|
# Recursively find the first element whose AXLabel contains the given text
|
|
def find_element_with_label_text(node, text)
|
|
case node
|
|
when Hash
|
|
if node['AXLabel']&.include?(text) && node['frame']
|
|
return node
|
|
end
|
|
node.each_value do |value|
|
|
result = find_element_with_label_text(value, text)
|
|
return result if result
|
|
end
|
|
when Array
|
|
node.each do |item|
|
|
result = find_element_with_label_text(item, text)
|
|
return result if result
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
# Tap the first element matching an accessibility identifier
|
|
# iOS 26 sometimes returns multiple elements with the same ID (e.g., row children)
|
|
# This finds the first one and taps its center coordinates
|
|
# @param identifier [String] The accessibility identifier to find
|
|
def tap_first_element_with_id(identifier)
|
|
tree = @axe.describe_ui
|
|
element = find_first_element_with_id(tree, identifier)
|
|
raise UITest::Axe::AxeError, "No element found with id '#{identifier}'" unless element
|
|
|
|
frame = element['frame']
|
|
x = frame['x'] + (frame['width'] / 2)
|
|
y = frame['y'] + (frame['height'] / 2)
|
|
@axe.tap_coordinates(x: x, y: y)
|
|
end
|
|
|
|
# Recursively find the first element with a matching AXUniqueId
|
|
# @param node [Hash, Array] Current node in the tree
|
|
# @param identifier [String] The identifier to match
|
|
# @return [Hash, nil] The element or nil if not found
|
|
def find_first_element_with_id(node, identifier)
|
|
case node
|
|
when Hash
|
|
return node if node['AXUniqueId'] == identifier
|
|
|
|
node.each_value do |value|
|
|
result = find_first_element_with_id(value, identifier)
|
|
return result if result
|
|
end
|
|
when Array
|
|
node.each do |item|
|
|
result = find_first_element_with_id(item, identifier)
|
|
return result if result
|
|
end
|
|
end
|
|
nil
|
|
end
|
|
|
|
# Generic method to add an instance via Detect & Add flow
|
|
# @param url [String] Full URL of the instance
|
|
def add_instance(url)
|
|
navigate_to_sources
|
|
|
|
# Tap Add Source button (toolbar or empty state)
|
|
tap_add_source_button
|
|
sleep 0.8
|
|
|
|
# Select Remote Server from the source type list
|
|
select_remote_server_tab
|
|
|
|
# Wait for URL field to appear
|
|
wait_for_element('addRemoteServer.urlField')
|
|
|
|
# Enter URL in text field
|
|
@axe.tap_id('addRemoteServer.urlField')
|
|
sleep 0.5
|
|
@axe.type(url)
|
|
sleep 0.5
|
|
|
|
# Tap Detect button to identify server type
|
|
@axe.tap_id('addRemoteServer.detectButton')
|
|
sleep 0.5
|
|
|
|
# Wait for detection to complete
|
|
result = wait_for_detection(timeout: 20)
|
|
raise "Detection failed: #{result}" if result == :error
|
|
|
|
# Wait for action button to appear, then tap it
|
|
wait_for_element('addRemoteServer.actionButton')
|
|
@axe.tap_id('addRemoteServer.actionButton')
|
|
sleep 0.5
|
|
|
|
# Wait for sheet to dismiss after adding
|
|
wait_for_add_complete(timeout: 20)
|
|
|
|
# Close Settings (return to Home)
|
|
close_settings
|
|
end
|
|
|
|
# Generic method to remove an instance by host text
|
|
# @param row_id [String] Full accessibility identifier for the row (unused, kept for API compat)
|
|
# @param host [String] Host name for logging and text-based lookup
|
|
# @return [Boolean] true if removed, false if not found
|
|
def remove_instance(row_id, host)
|
|
navigate_to_sources
|
|
|
|
# Verify the row exists (using text since accessibilityIdentifier doesn't expose as AXUniqueId)
|
|
unless @axe.text_visible?(host)
|
|
puts " Instance not found: #{host}"
|
|
close_settings
|
|
return false
|
|
end
|
|
|
|
# Swipe left on the row to reveal delete button
|
|
# First, find approximate position of the row (middle of screen, adjust as needed)
|
|
@axe.swipe(start_x: 350, start_y: 200, end_x: 50, end_y: 200, duration: 0.3)
|
|
sleep 0.3
|
|
|
|
# Tap the Delete button that appears
|
|
begin
|
|
@axe.tap_label('Delete')
|
|
sleep 0.5
|
|
rescue UITest::Axe::AxeError
|
|
# Try tapping by coordinates if label doesn't work
|
|
@axe.tap_coordinates(x: 370, y: 200)
|
|
sleep 0.5
|
|
end
|
|
|
|
# Close Settings
|
|
close_settings
|
|
true
|
|
end
|
|
|
|
# Navigate from Home to Settings > Sources
|
|
def navigate_to_sources
|
|
# Ensure we're on Home tab
|
|
ensure_on_home
|
|
|
|
# Tap Settings button using accessibility identifier
|
|
@axe.tap_id('home.settingsButton')
|
|
sleep 1
|
|
|
|
# Wait for Settings view
|
|
wait_for_element('settings.view')
|
|
|
|
# Tap Sources row
|
|
@axe.tap_id('settings.row.sources')
|
|
sleep 1
|
|
|
|
# Wait for Sources list to load
|
|
# sources.view works for empty state (ContentUnavailableView)
|
|
# For populated list, check for the "Remote Servers" section header text
|
|
wait_for_sources_list
|
|
end
|
|
|
|
# Ensure we're on the Home tab
|
|
def ensure_on_home
|
|
# Check for Home navigation title or a home element
|
|
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
|
|
# Try to dismiss any sheets/modals
|
|
dismiss_any_sheets
|
|
|
|
# Final check - use a longer timeout
|
|
wait_for_home
|
|
end
|
|
|
|
# Try various methods to dismiss sheets/modals
|
|
def dismiss_any_sheets
|
|
# Try Done button by ID
|
|
begin
|
|
@axe.tap_id('settings.doneButton')
|
|
sleep 0.5
|
|
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
rescue UITest::Axe::AxeError
|
|
# Not found
|
|
end
|
|
|
|
# Try Done by label
|
|
begin
|
|
@axe.tap_id('settings.doneButton')
|
|
sleep 0.5
|
|
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
rescue UITest::Axe::AxeError
|
|
# Not found
|
|
end
|
|
|
|
# Try swipe down to dismiss
|
|
@axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
|
|
sleep 0.5
|
|
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
|
|
# Last resort: home button
|
|
@axe.home_button
|
|
sleep 1
|
|
end
|
|
|
|
# Wait for Home view to appear
|
|
def wait_for_home(timeout: Config.element_timeout)
|
|
start_time = Time.now
|
|
|
|
loop do
|
|
# Check for Home title or a home element
|
|
return true if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
|
|
raise "Home not found after #{timeout} seconds" if Time.now - start_time > timeout
|
|
|
|
sleep 0.3
|
|
end
|
|
end
|
|
|
|
# Close Settings sheet - handles navigation back from sub-views
|
|
def close_settings
|
|
# Try swipe down to dismiss the sheet (most reliable)
|
|
@axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
|
|
sleep 0.5
|
|
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
|
|
|
|
# Try Done button by ID
|
|
begin
|
|
@axe.tap_id('settings.doneButton')
|
|
sleep 0.5
|
|
return
|
|
rescue UITest::Axe::AxeError
|
|
# Not found
|
|
end
|
|
|
|
# Try Done by label
|
|
begin
|
|
@axe.tap_id('settings.doneButton')
|
|
sleep 0.5
|
|
rescue UITest::Axe::AxeError
|
|
# Not found
|
|
end
|
|
end
|
|
|
|
# Wait for detection to complete
|
|
# @param timeout [Integer] Timeout in seconds (default: 20 for network operations)
|
|
# @return [Symbol] :success or :error
|
|
def wait_for_detection(timeout: 20)
|
|
start_time = Time.now
|
|
|
|
loop do
|
|
# Check for success (detected type shown)
|
|
if @axe.element_exists?('addRemoteServer.detectedType')
|
|
puts " Detection succeeded after #{(Time.now - start_time).round(1)}s"
|
|
return :success
|
|
end
|
|
|
|
# Check for error
|
|
if @axe.element_exists?('addRemoteServer.detectionError')
|
|
puts " Detection failed with error after #{(Time.now - start_time).round(1)}s"
|
|
return :error
|
|
end
|
|
|
|
# Check for timeout
|
|
elapsed = Time.now - start_time
|
|
raise "Detection timed out after #{timeout} seconds" if elapsed > timeout
|
|
|
|
sleep 0.5
|
|
end
|
|
end
|
|
|
|
# Wait for the add source sheet to dismiss after tapping Add Source
|
|
# @param timeout [Integer] Timeout in seconds
|
|
def wait_for_add_complete(timeout: 20)
|
|
start_time = Time.now
|
|
|
|
loop do
|
|
# Check if the add form has been dismissed (URL field no longer visible)
|
|
unless @axe.element_exists?('addRemoteServer.urlField')
|
|
puts " Source added successfully after #{(Time.now - start_time).round(1)}s"
|
|
return
|
|
end
|
|
|
|
elapsed = Time.now - start_time
|
|
raise "Adding source timed out after #{timeout} seconds" if elapsed > timeout
|
|
|
|
sleep 0.5
|
|
end
|
|
end
|
|
|
|
# Wait for Sources list to be visible (works for both empty and populated states)
|
|
def wait_for_sources_list(timeout: Config.element_timeout)
|
|
start_time = Time.now
|
|
|
|
loop do
|
|
# Empty state has sources.view on ContentUnavailableView
|
|
return true if @axe.element_exists?('sources.view')
|
|
|
|
# Populated state: check for section headers or instance rows
|
|
return true if @axe.text_visible?('Remote Servers')
|
|
return true if @axe.text_visible?('Local & Network')
|
|
|
|
raise "Sources list not found after #{timeout} seconds" if Time.now - start_time > timeout
|
|
|
|
sleep 0.3
|
|
end
|
|
end
|
|
|
|
# Find the authentication text fields (username/password) after the "Authentication" header
|
|
# @return [Array<Hash>] Array of text field elements
|
|
def find_auth_text_fields
|
|
tree = @axe.describe_ui
|
|
fields = []
|
|
found_auth_header = false
|
|
|
|
collect_auth_fields = lambda do |node|
|
|
return unless node.is_a?(Hash)
|
|
|
|
# Look for "Authentication" heading
|
|
if node['role'] == 'AXHeading' && node['AXLabel']&.include?('Authentication')
|
|
found_auth_header = true
|
|
end
|
|
|
|
# Collect text fields after the auth header
|
|
if found_auth_header && node['role'] == 'AXTextField'
|
|
fields << node
|
|
end
|
|
|
|
# Stop after finding the action button (past the auth section)
|
|
return if node['AXUniqueId'] == 'addRemoteServer.actionButton'
|
|
|
|
(node['children'] || []).each { |child| collect_auth_fields.call(child) }
|
|
end
|
|
|
|
if tree.is_a?(Array)
|
|
tree.each { |root| collect_auth_fields.call(root) }
|
|
end
|
|
|
|
fields
|
|
end
|
|
|
|
# Find the username/password text fields under the "HTTP Basic Authentication"
|
|
# section header in AddRemoteServerView. Mirrors find_auth_text_fields but
|
|
# anchored on the localized header used for non-Yattee-Server instance types.
|
|
# Falls back to a plain "Authentication" header for Yattee Server forms.
|
|
# @return [Array<Hash>] Array of text field elements
|
|
def find_basic_auth_text_fields
|
|
tree = @axe.describe_ui
|
|
fields = []
|
|
found_header = false
|
|
|
|
collect = lambda do |node|
|
|
return unless node.is_a?(Hash)
|
|
|
|
if node['role'] == 'AXHeading' &&
|
|
(node['AXLabel']&.include?('HTTP Basic Authentication') ||
|
|
node['AXLabel']&.include?('Authentication'))
|
|
found_header = true
|
|
end
|
|
|
|
if found_header && (node['role'] == 'AXTextField' || node['role'] == 'AXSecureTextField')
|
|
fields << node
|
|
end
|
|
|
|
return if node['AXUniqueId'] == 'addRemoteServer.actionButton' ||
|
|
node['AXUniqueId'] == 'addRemoteServer.retryDetectionButton'
|
|
|
|
(node['children'] || []).each { |child| collect.call(child) }
|
|
end
|
|
|
|
if tree.is_a?(Array)
|
|
tree.each { |root| collect.call(root) }
|
|
end
|
|
|
|
fields
|
|
end
|
|
|
|
# Tap a text field discovered via the accessibility tree and type into it.
|
|
# @param field [Hash] Element hash from describe_ui (must have a 'frame')
|
|
# @param text [String] Text to type after focusing the field
|
|
def fill_field(field, text)
|
|
frame = field['frame']
|
|
@axe.tap_coordinates(
|
|
x: frame['x'] + (frame['width'] / 2),
|
|
y: frame['y'] + (frame['height'] / 2)
|
|
)
|
|
sleep 0.3
|
|
@axe.type(text)
|
|
sleep 0.3
|
|
end
|
|
|
|
# Debug helper to print UI element tree
|
|
def print_element_tree(node, depth = 0)
|
|
return unless node.is_a?(Hash)
|
|
|
|
uid = node['AXUniqueId']
|
|
label = node['AXLabel']
|
|
role = node['role']
|
|
frame = node['frame'] || {}
|
|
puts " #{' ' * depth}#{uid || '(none)'} [#{role}] (#{frame['x']&.round},#{frame['y']&.round} #{frame['width']&.round}x#{frame['height']&.round}) - #{label}"
|
|
(node['children'] || []).each { |child| print_element_tree(child, depth + 1) }
|
|
end
|
|
|
|
# Tap Add Source button - handles both empty state (body button) and non-empty state (toolbar button)
|
|
def tap_add_source_button
|
|
# Try the body button label first (works for empty state)
|
|
begin
|
|
@axe.tap_label('Add Source')
|
|
return
|
|
rescue UITest::Axe::AxeError
|
|
# Not found - try toolbar button
|
|
end
|
|
|
|
# Try the toolbar + button by ID
|
|
begin
|
|
@axe.tap_id('sources.addButton')
|
|
return
|
|
rescue UITest::Axe::AxeError
|
|
# Not found - try coordinates
|
|
end
|
|
|
|
# Fallback: tap the toolbar + button by coordinates (top-right area)
|
|
# On iPhone 17 Pro (402pt width), the + button is in the nav bar at ~(370, 93)
|
|
@axe.tap_coordinates(x: 370, y: 93)
|
|
end
|
|
|
|
# Navigate to the Remote Server form in the AddSourceView
|
|
# The AddSourceView shows a list of source types
|
|
def select_remote_server_tab
|
|
@axe.tap_label('Add Remote Server')
|
|
sleep 0.5
|
|
end
|
|
|
|
# Wait for an element to appear
|
|
# @param identifier [String] Accessibility identifier
|
|
# @param timeout [Integer] Timeout in seconds
|
|
def wait_for_element(identifier, timeout: Config.element_timeout)
|
|
start_time = Time.now
|
|
|
|
loop do
|
|
return true if @axe.element_exists?(identifier)
|
|
|
|
raise "Element '#{identifier}' not found after #{timeout} seconds" if Time.now - start_time > timeout
|
|
|
|
sleep 0.3
|
|
end
|
|
end
|
|
end
|
|
end
|