diff --git a/Yattee/Views/Settings/SourceRow.swift b/Yattee/Views/Settings/SourceRow.swift index 48c82b8f..aad2a42f 100644 --- a/Yattee/Views/Settings/SourceRow.swift +++ b/Yattee/Views/Settings/SourceRow.swift @@ -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. diff --git a/Yattee/Views/Settings/SourcesListView.swift b/Yattee/Views/Settings/SourcesListView.swift index e64c7541..a3ab1e23 100644 --- a/Yattee/Views/Settings/SourcesListView.swift +++ b/Yattee/Views/Settings/SourcesListView.swift @@ -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 diff --git a/spec/ui/smoke/app_launch_spec.rb b/spec/ui/smoke/app_launch_spec.rb index 1f33b4bb..7da9b45c 100644 --- a/spec/ui/smoke/app_launch_spec.rb +++ b/spec/ui/smoke/app_launch_spec.rb @@ -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 diff --git a/spec/ui/smoke/import_playlists_piped_spec.rb b/spec/ui/smoke/import_playlists_piped_spec.rb index 1ba174b7..689eee7e 100644 --- a/spec/ui/smoke/import_playlists_piped_spec.rb +++ b/spec/ui/smoke/import_playlists_piped_spec.rb @@ -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 diff --git a/spec/ui/smoke/import_playlists_spec.rb b/spec/ui/smoke/import_playlists_spec.rb index 29b134ca..f10be823 100644 --- a/spec/ui/smoke/import_playlists_spec.rb +++ b/spec/ui/smoke/import_playlists_spec.rb @@ -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 diff --git a/spec/ui/smoke/import_subscriptions_piped_spec.rb b/spec/ui/smoke/import_subscriptions_piped_spec.rb index a5f8a430..ebe0b2a0 100644 --- a/spec/ui/smoke/import_subscriptions_piped_spec.rb +++ b/spec/ui/smoke/import_subscriptions_piped_spec.rb @@ -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 diff --git a/spec/ui/smoke/import_subscriptions_spec.rb b/spec/ui/smoke/import_subscriptions_spec.rb index 86553277..614f2d50 100644 --- a/spec/ui/smoke/import_subscriptions_spec.rb +++ b/spec/ui/smoke/import_subscriptions_spec.rb @@ -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 diff --git a/spec/ui/smoke/invidious_spec.rb b/spec/ui/smoke/invidious_spec.rb index de9fd3a3..b1c5e6e1 100644 --- a/spec/ui/smoke/invidious_spec.rb +++ b/spec/ui/smoke/invidious_spec.rb @@ -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 diff --git a/spec/ui/smoke/piped_spec.rb b/spec/ui/smoke/piped_spec.rb index efd4da1e..e4d85b27 100644 --- a/spec/ui/smoke/piped_spec.rb +++ b/spec/ui/smoke/piped_spec.rb @@ -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 diff --git a/spec/ui/smoke/settings_spec.rb b/spec/ui/smoke/settings_spec.rb index 6307ff71..9d1c7a0f 100644 --- a/spec/ui/smoke/settings_spec.rb +++ b/spec/ui/smoke/settings_spec.rb @@ -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 diff --git a/spec/ui/smoke/yattee_server_spec.rb b/spec/ui/smoke/yattee_server_spec.rb index 7369a99d..61060bec 100644 --- a/spec/ui/smoke/yattee_server_spec.rb +++ b/spec/ui/smoke/yattee_server_spec.rb @@ -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 diff --git a/spec/ui/support/app.rb b/spec/ui/support/app.rb index 15a6698b..40503a3e 100644 --- a/spec/ui/support/app.rb +++ b/spec/ui/support/app.rb @@ -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? diff --git a/spec/ui/support/config.rb b/spec/ui/support/config.rb index 08d972c8..d1d4ec84 100644 --- a/spec/ui/support/config.rb +++ b/spec/ui/support/config.rb @@ -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') diff --git a/spec/ui/support/instance_setup.rb b/spec/ui/support/instance_setup.rb index bf72d4d7..2864fbc6 100644 --- a/spec/ui/support/instance_setup.rb +++ b/spec/ui/support/instance_setup.rb @@ -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] 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 diff --git a/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-home.png b/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-home.png new file mode 100644 index 00000000..81033199 Binary files /dev/null and b/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-home.png differ diff --git a/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-library.png b/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-library.png index 4f1b97e0..81033199 100644 Binary files a/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-library.png and b/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/app-launch-library.png differ diff --git a/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/settings-main.png b/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/settings-main.png index 66420c42..6f673730 100644 Binary files a/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/settings-main.png and b/spec/ui_snapshots/baseline/iPhone_17_Pro/iOS_26_2/settings-main.png differ