Fix UI tests for onboarding flow and AddRemoteServer redesign

- Skip onboarding in tests by setting UserDefaults before launch
- Update all addSource.* identifiers to addRemoteServer.* for new flow
- Switch from identifier-based to text-based element lookups (iOS 26 AXe limitation)
- Add Yattee Server credential support in instance setup
- Update baseline screenshots for Home tab and settings
This commit is contained in:
Arkadiusz Fal
2026-02-10 00:05:39 +01:00
parent 3905fd8b18
commit 9cd9506dcf
17 changed files with 350 additions and 150 deletions

View File

@@ -38,10 +38,13 @@ struct SourceRow: View {
rowContent
}
.buttonStyle(.card)
.accessibilityIdentifier(accessibilityId)
#else
rowContent
.contentShape(Rectangle())
.onTapGesture(perform: onEdit)
.accessibilityElement(children: .combine)
.accessibilityIdentifier(accessibilityId)
#endif
}
@@ -72,7 +75,6 @@ struct SourceRow: View {
Spacer()
}
.accessibilityIdentifier(accessibilityId)
}
/// Generates a unique accessibility identifier for the source row.

View File

@@ -42,7 +42,6 @@ struct SourcesListView: View {
sourcesList
}
}
.accessibilityIdentifier("sources.view")
.navigationTitle(String(localized: "sources.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
@@ -91,6 +90,7 @@ struct SourcesListView: View {
}
.buttonStyle(.borderedProminent)
}
.accessibilityIdentifier("sources.view")
}
// MARK: - Sources List

View File

@@ -32,39 +32,33 @@ RSpec.describe 'App Launch', :smoke do
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'Library tab' do
it 'displays the Library navigation bar' do
# With toolbarTitleDisplayMode(.inlineLarge), the title is an AXHeading with AXLabel "Library"
# but no AXUniqueId, so we check for the text instead
expect(@axe).to have_text('Library')
describe 'Home tab' do
it 'displays the Home navigation bar' do
expect(@axe).to have_text('Home')
end
it 'displays the Tab Bar' do
expect(@axe).to have_text('Tab Bar')
end
it 'displays the Playlists card' do
expect(@axe).to have_element('library.card.playlists')
it 'displays the Open Link shortcut' do
expect(@axe).to have_element('home.shortcut.openURL')
end
it 'displays the Bookmarks card' do
expect(@axe).to have_element('library.card.bookmarks')
it 'displays the Bookmarks shortcut' do
expect(@axe).to have_element('home.shortcut.bookmarks')
end
it 'displays the History card' do
expect(@axe).to have_element('library.card.history')
it 'displays the History shortcut' do
expect(@axe).to have_element('home.shortcut.history')
end
it 'displays the Downloads card' do
expect(@axe).to have_element('library.card.downloads')
end
it 'displays the Channels card' do
expect(@axe).to have_element('library.card.channels')
it 'displays the Settings button' do
expect(@axe).to have_element('home.settingsButton')
end
it 'matches the baseline screenshot', :visual do
screenshot = @axe.screenshot('app-launch-library')
screenshot = @axe.screenshot('app-launch-home')
expect(screenshot).to match_baseline
end
end

View File

@@ -43,7 +43,7 @@ RSpec.describe 'Import Playlists from Piped', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Piped row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.piped.#{UITest::Config.piped_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.piped_host)
sleep 0.8
# Wait for EditSourceView
@@ -71,7 +71,7 @@ RSpec.describe 'Import Playlists from Piped', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Piped row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.piped.#{UITest::Config.piped_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.piped_host)
sleep 0.8
# Wait for EditSourceView

View File

@@ -43,7 +43,7 @@ RSpec.describe 'Import Playlists from Invidious', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Invidious row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.invidious.#{UITest::Config.invidious_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.invidious_host)
sleep 0.8
# Wait for EditSourceView
@@ -71,7 +71,7 @@ RSpec.describe 'Import Playlists from Invidious', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Invidious row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.invidious.#{UITest::Config.invidious_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.invidious_host)
sleep 0.8
# Wait for EditSourceView

View File

@@ -43,7 +43,7 @@ RSpec.describe 'Import Subscriptions from Piped', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Piped row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.piped.#{UITest::Config.piped_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.piped_host)
sleep 0.8
# Wait for EditSourceView
@@ -71,7 +71,7 @@ RSpec.describe 'Import Subscriptions from Piped', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Piped row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.piped.#{UITest::Config.piped_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.piped_host)
sleep 0.8
# Wait for EditSourceView

View File

@@ -43,7 +43,7 @@ RSpec.describe 'Import Subscriptions from Invidious', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Invidious row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.invidious.#{UITest::Config.invidious_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.invidious_host)
sleep 0.8
# Wait for EditSourceView
@@ -71,7 +71,7 @@ RSpec.describe 'Import Subscriptions from Invidious', :smoke do
@instance_setup.send(:navigate_to_sources)
# Tap the Invidious row (using helper due to iOS 26 multiple-match issue)
@instance_setup.send(:tap_first_element_with_id, "sources.row.invidious.#{UITest::Config.invidious_host}")
@instance_setup.send(:tap_element_containing_text, UITest::Config.invidious_host)
sleep 0.8
# Wait for EditSourceView

View File

@@ -38,15 +38,15 @@ RSpec.describe 'Invidious Instance', :smoke do
invidious_url = UITest::Config.invidious_url
invidious_host = UITest::Config.invidious_host
# Ensure we start from Library (check for text since inlineLarge title has no AXUniqueId)
expect(@axe).to have_text('Library')
# Ensure we start from Home
expect(@axe).to have_text('Home')
# Remove existing instance (if any) and add fresh - always tests the add flow
@instance_setup.remove_and_add_invidious(invidious_url)
# Navigate to Sources to verify the instance was added
# Open Settings using accessibility identifier
@axe.tap_id('library.settingsButton')
@axe.tap_id('home.settingsButton')
sleep 1
expect(@axe).to have_element('settings.view')
@@ -55,15 +55,13 @@ RSpec.describe 'Invidious Instance', :smoke do
@axe.tap_id('settings.row.sources')
sleep 0.5
# Verify we're on Sources list
expect(@axe).to have_element('sources.view')
# Verify instance appears in Sources list
expect(@axe).to have_element("sources.row.invidious.#{invidious_host}")
# Verify instance appears in Sources list (check by host text since
# SwiftUI accessibilityIdentifier doesn't expose as AXUniqueId)
expect(@axe).to have_text(invidious_host)
# Close settings
begin
@axe.tap_label('Done')
@axe.tap_id('settings.doneButton')
rescue StandardError
nil
end

View File

@@ -38,15 +38,15 @@ RSpec.describe 'Piped Instance', :smoke do
piped_url = UITest::Config.piped_url
piped_host = UITest::Config.piped_host
# Ensure we start from Library (check for text since inlineLarge title has no AXUniqueId)
expect(@axe).to have_text('Library')
# Ensure we start from Home
expect(@axe).to have_text('Home')
# Remove existing instance (if any) and add fresh - always tests the add flow
@instance_setup.remove_and_add_piped(piped_url)
# Navigate to Sources to verify the instance was added
# Open Settings using accessibility identifier
@axe.tap_id('library.settingsButton')
@axe.tap_id('home.settingsButton')
sleep 1
expect(@axe).to have_element('settings.view')
@@ -55,15 +55,12 @@ RSpec.describe 'Piped Instance', :smoke do
@axe.tap_id('settings.row.sources')
sleep 0.5
# Verify we're on Sources list
expect(@axe).to have_element('sources.view')
# Verify instance appears in Sources list
expect(@axe).to have_element("sources.row.piped.#{piped_host}")
expect(@axe).to have_text(piped_host)
# Close settings
begin
@axe.tap_label('Done')
@axe.tap_id('settings.doneButton')
rescue StandardError
nil
end

View File

@@ -32,20 +32,20 @@ RSpec.describe 'Settings', :smoke do
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'opening Settings from Library' do
describe 'opening Settings from Home' do
before(:all) do
# Ensure we're on Library tab first (check for text since inlineLarge title has no AXUniqueId)
expect(@axe).to have_text('Library')
# Ensure we're on Home tab first
expect(@axe).to have_text('Home')
# Tap Settings button using accessibility identifier
@axe.tap_id('library.settingsButton')
@axe.tap_id('home.settingsButton')
sleep 1
end
after(:all) do
# Close Settings to return to Library
# Close Settings to return to Home
begin
@axe.tap_label('Done')
@axe.tap_id('settings.doneButton')
rescue StandardError
nil
end
@@ -60,7 +60,7 @@ RSpec.describe 'Settings', :smoke do
expect(@axe).to have_text('Settings')
end
it 'displays the Done button' do
it 'displays the Close button' do
expect(@axe).to have_element('settings.doneButton')
end
@@ -83,24 +83,33 @@ RSpec.describe 'Settings', :smoke do
end
describe 'closing Settings' do
before(:all) do
# Open settings if not already open
unless @axe.element_exists?('settings.view')
@axe.tap_id('library.settingsButton')
sleep 1
it 'closes Settings when tapping Close' do
# Ensure we're on Home first
sleep 0.5
unless @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
# Try to dismiss any open sheets
begin
@axe.tap_id('settings.doneButton')
sleep 0.5
rescue StandardError
nil
end
end
end
it 'closes Settings when tapping Done' do
# Open Settings fresh
@axe.tap_id('home.settingsButton')
sleep 1
# Verify we're in Settings
expect(@axe).to have_element('settings.view')
expect(@axe).to have_element('settings.doneButton')
# Tap Done
@axe.tap_label('Done')
sleep 0.5
# Tap Close
@axe.tap_id('settings.doneButton')
sleep 1.5
# Verify we're back on Library (check for text since inlineLarge title has no AXUniqueId)
expect(@axe).to have_text('Library')
# Verify we're back on Home
expect(@axe).to have_text('Home')
expect(@axe).not_to have_element('settings.view')
end
end

View File

@@ -38,15 +38,15 @@ RSpec.describe 'Yattee Server Instance', :smoke do
server_url = UITest::Config.yattee_server_url
server_host = UITest::Config.yattee_server_host
# Ensure we start from Library (check for text since inlineLarge title has no AXUniqueId)
expect(@axe).to have_text('Library')
# Ensure we start from Home
expect(@axe).to have_text('Home')
# Remove existing instance (if any) and add fresh - always tests the add flow
@instance_setup.remove_and_add_yattee_server(server_url)
# Navigate to Sources to verify the instance was added
# Open Settings using accessibility identifier
@axe.tap_id('library.settingsButton')
@axe.tap_id('home.settingsButton')
sleep 1
expect(@axe).to have_element('settings.view')
@@ -55,15 +55,12 @@ RSpec.describe 'Yattee Server Instance', :smoke do
@axe.tap_id('settings.row.sources')
sleep 0.5
# Verify we're on Sources list
expect(@axe).to have_element('sources.view')
# Verify instance appears in Sources list
expect(@axe).to have_element("sources.row.yatteeServer.#{server_host}")
expect(@axe).to have_text(server_host)
# Close settings
begin
@axe.tap_label('Done')
@axe.tap_id('settings.doneButton')
rescue StandardError
nil
end

View File

@@ -75,7 +75,12 @@ module UITest
# Terminate if already running
terminate(udid: udid, silent: true)
output, status = Open3.capture2e('xcrun', 'simctl', 'launch', udid, Config.bundle_id)
# Skip onboarding in tests by passing launch argument
# iOS apps accept UserDefaults overrides via launch arguments
output, status = Open3.capture2e(
'xcrun', 'simctl', 'launch', udid, Config.bundle_id,
'-onboardingCompleted', 'YES'
)
raise AppError, "Launch failed: #{output}" unless status.success?

View File

@@ -72,6 +72,21 @@ module UITest
URI.parse(yattee_server_url).host
end
# Yattee Server username for testing (configurable via env)
def yattee_server_username
ENV.fetch('YATTEE_SERVER_USERNAME', nil)
end
# Yattee Server password for testing (configurable via env)
def yattee_server_password
ENV.fetch('YATTEE_SERVER_PASSWORD', nil)
end
# Whether Yattee Server credentials are configured
def yattee_server_credentials?
yattee_server_username && yattee_server_password
end
# Invidious URL for testing (configurable via env)
def invidious_url
ENV.fetch('INVIDIOUS_URL', 'https://invidious.home.arekf.net')

View File

@@ -7,7 +7,7 @@ module UITest
# Provides methods to navigate through Settings and add/verify instances.
class InstanceSetup
# Coordinates for iPhone 17 Pro (393pt width)
# Settings gear button in Library toolbar (top-right)
# Settings gear button in Home toolbar (top-right)
SETTINGS_BUTTON_COORDS = { x: 380, y: 70 }.freeze
def initialize(axe)
@@ -19,7 +19,7 @@ module UITest
# @return [Boolean] true if instance exists
def yattee_server_exists?(host)
navigate_to_sources
exists = @axe.element_exists?("sources.row.yatteeServer.#{host}")
exists = @axe.text_visible?(host)
close_settings
exists
end
@@ -29,34 +29,72 @@ module UITest
def add_yattee_server(url)
navigate_to_sources
# Tap Add Source button in toolbar (using coordinates - iOS 26 doesn't expose toolbar buttons in accessibility tree)
# The button is in the top-right of the navigation bar at approximately (370, 105)
@axe.tap_coordinates(x: 370, y: 105)
# Tap Add Source button (toolbar or empty state)
tap_add_source_button
sleep 0.8
# Wait for AddSourceView to appear
wait_for_element('addSource.urlField')
# 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('addSource.urlField')
@axe.tap_id('addRemoteServer.urlField')
sleep 0.5
@axe.type(url)
sleep 0.5
# Tap Detect & Add button
@axe.tap_id('addSource.actionButton')
# Tap Detect button to identify server type
@axe.tap_id('addRemoteServer.detectButton')
sleep 0.5
# Wait for detection to complete
# Use longer timeout for first detection (network cold start)
result = wait_for_detection(timeout: 20)
raise "Detection failed: #{result}" if result == :error
# The sheet auto-dismisses on success
# If we're already back on sources.view, no need to wait or close
sleep 1.5 unless @axe.element_exists?('sources.view')
# 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
# Close Settings (return to Library)
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
@@ -101,7 +139,7 @@ module UITest
# @return [Boolean] true if instance exists
def invidious_exists?(host)
navigate_to_sources
exists = @axe.element_exists?("sources.row.invidious.#{host}")
exists = @axe.text_visible?(host)
close_settings
exists
end
@@ -156,7 +194,7 @@ module UITest
# Tap on Invidious instance row to open EditSourceView
# Due to iOS 26 accessibility issues, use coordinates from the element
tap_first_element_with_id("sources.row.invidious.#{host}")
tap_element_containing_text(host)
sleep 0.8
# Check if "Log Out" is visible (indicates logged in)
@@ -181,7 +219,7 @@ module UITest
# Tap on Invidious instance row to open EditSourceView
# Due to iOS 26 accessibility issues, use coordinates from the element
tap_first_element_with_id("sources.row.invidious.#{host}")
tap_element_containing_text(host)
sleep 0.8
# Wait for EditSourceView
@@ -326,7 +364,7 @@ module UITest
puts ' Login succeeded'
# Close edit sheet and return to Library
# 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
@@ -342,8 +380,8 @@ module UITest
close_settings
sleep 0.5
# Verify we're back on Library, attempt recovery if not
unless @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
# 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
@@ -370,7 +408,7 @@ module UITest
# @return [Boolean] true if instance exists
def piped_exists?(host)
navigate_to_sources
exists = @axe.element_exists?("sources.row.piped.#{host}")
exists = @axe.text_visible?(host)
close_settings
exists
end
@@ -425,7 +463,7 @@ module UITest
# Tap on Piped instance row to open EditSourceView
# Due to iOS 26 accessibility issues, use coordinates from the element
tap_first_element_with_id("sources.row.piped.#{host}")
tap_element_containing_text(host)
sleep 0.8
# Check if "Log Out" is visible (indicates logged in)
@@ -450,7 +488,7 @@ module UITest
# Tap on Piped instance row to open EditSourceView
# Due to iOS 26 accessibility issues, use coordinates from the element
tap_first_element_with_id("sources.row.piped.#{host}")
tap_element_containing_text(host)
sleep 0.8
# Wait for EditSourceView
@@ -595,7 +633,7 @@ module UITest
puts ' Login succeeded'
# Close edit sheet and return to Library
# 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
@@ -611,8 +649,8 @@ module UITest
close_settings
sleep 0.5
# Verify we're back on Library, attempt recovery if not
unless @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
# 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
@@ -652,6 +690,39 @@ module UITest
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
@@ -694,46 +765,51 @@ module UITest
def add_instance(url)
navigate_to_sources
# Tap Add Source button in toolbar (using coordinates - iOS 26 doesn't expose toolbar buttons in accessibility tree)
# The button is in the top-right of the navigation bar at approximately (370, 105)
@axe.tap_coordinates(x: 370, y: 105)
# Tap Add Source button (toolbar or empty state)
tap_add_source_button
sleep 0.8
# Wait for AddSourceView to appear
wait_for_element('addSource.urlField')
# 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('addSource.urlField')
@axe.tap_id('addRemoteServer.urlField')
sleep 0.5
@axe.type(url)
sleep 0.5
# Tap Detect & Add button
@axe.tap_id('addSource.actionButton')
# Tap Detect button to identify server type
@axe.tap_id('addRemoteServer.detectButton')
sleep 0.5
# Wait for detection to complete
# Use longer timeout for first detection (network cold start)
result = wait_for_detection(timeout: 20)
raise "Detection failed: #{result}" if result == :error
# The sheet auto-dismisses on success
# If we're already back on sources.view, no need to wait or close
sleep 1.5 unless @axe.element_exists?('sources.view')
# Wait for action button to appear, then tap it
wait_for_element('addRemoteServer.actionButton')
@axe.tap_id('addRemoteServer.actionButton')
sleep 0.5
# Close Settings (return to Library)
# 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 row ID
# @param row_id [String] Full accessibility identifier for the row
# @param host [String] Host name for logging
# 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
unless @axe.element_exists?(row_id)
# 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
@@ -759,13 +835,13 @@ module UITest
true
end
# Navigate from Library to Settings > Sources
# Navigate from Home to Settings > Sources
def navigate_to_sources
# Ensure we're on Library tab
ensure_on_library
# Ensure we're on Home tab
ensure_on_home
# Tap Settings button using accessibility identifier
@axe.tap_id('library.settingsButton')
@axe.tap_id('home.settingsButton')
sleep 1
# Wait for Settings view
@@ -773,22 +849,24 @@ module UITest
# Tap Sources row
@axe.tap_id('settings.row.sources')
sleep 0.5
sleep 1
# Wait for Sources list view
wait_for_element('sources.view')
# 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 Library tab
def ensure_on_library
# Check for Library navigation title (text, since inlineLarge has no AXUniqueId) or a library card
return if @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
# 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_library
wait_for_home
end
# Try various methods to dismiss sheets/modals
@@ -797,16 +875,16 @@ module UITest
begin
@axe.tap_id('settings.doneButton')
sleep 0.5
return if @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
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_label('Done')
@axe.tap_id('settings.doneButton')
sleep 0.5
return if @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
rescue UITest::Axe::AxeError
# Not found
end
@@ -814,22 +892,22 @@ module UITest
# 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?('Library') || @axe.element_exists?('library.card.playlists')
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
# Last resort: home button
@axe.home_button
sleep 1
end
# Wait for Library view to appear
def wait_for_library(timeout: Config.element_timeout)
# Wait for Home view to appear
def wait_for_home(timeout: Config.element_timeout)
start_time = Time.now
loop do
# Check for Library title (text, since inlineLarge has no AXUniqueId) or a library card
return true if @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
# Check for Home title or a home element
return true if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
raise "Library not found after #{timeout} seconds" if Time.now - start_time > timeout
raise "Home not found after #{timeout} seconds" if Time.now - start_time > timeout
sleep 0.3
end
@@ -840,7 +918,7 @@ module UITest
# 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?('Library') || @axe.element_exists?('library.card.playlists')
return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
# Try Done button by ID
begin
@@ -853,7 +931,7 @@ module UITest
# Try Done by label
begin
@axe.tap_label('Done')
@axe.tap_id('settings.doneButton')
sleep 0.5
rescue UITest::Axe::AxeError
# Not found
@@ -868,24 +946,17 @@ module UITest
loop do
# Check for success (detected type shown)
if @axe.element_exists?('addSource.detectedType')
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?('addSource.detectionError')
if @axe.element_exists?('addRemoteServer.detectionError')
puts " Detection failed with error after #{(Time.now - start_time).round(1)}s"
return :error
end
# Check if the AddSourceView sheet was auto-dismissed after successful detection
# This happens when the instance is added - the sheet closes automatically
if @axe.element_exists?('sources.view') && !@axe.element_exists?('addSource.urlField')
puts " Detection succeeded (sheet auto-dismissed) after #{(Time.now - start_time).round(1)}s"
return :success
end
# Check for timeout
elapsed = Time.now - start_time
raise "Detection timed out after #{timeout} seconds" if elapsed > timeout
@@ -894,6 +965,118 @@ module UITest
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
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 210 KiB