From cae1226cfe29a790bd817e6584a0b46a65c5f007 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 10 Feb 2026 05:45:19 +0100 Subject: [PATCH] Add URL scheme UI tests for deep link navigation Test yattee:// custom scheme URLs navigate to correct screens: playlists, bookmarks, history, downloads, channels, subscriptions, continue-watching, and search. Handles iOS system confirmation dialog via coordinate taps since it's invisible to AXe. Settings deep link is excluded (known app bug - doesn't render when pushed to nav stack). --- spec/ui/smoke/url_schemes_spec.rb | 392 ++++++++++++++++++++++++++++++ spec/ui/support/simulator.rb | 8 + 2 files changed, 400 insertions(+) create mode 100644 spec/ui/smoke/url_schemes_spec.rb diff --git a/spec/ui/smoke/url_schemes_spec.rb b/spec/ui/smoke/url_schemes_spec.rb new file mode 100644 index 00000000..7f7a731e --- /dev/null +++ b/spec/ui/smoke/url_schemes_spec.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +RSpec.describe 'URL Schemes', :smoke do + before(:all) do + @udid = UITest::Simulator.boot(UITest::Config.device) + + UITest::App.build( + device: UITest::Config.device, + skip: UITest::Config.skip_build? + ) + + UITest::App.install(udid: @udid) + UITest::App.launch(udid: @udid) + + sleep UITest::Config.app_launch_wait + + @axe = UITest::Axe.new(@udid) + end + + after(:all) do + UITest::App.terminate(udid: @udid, silent: true) if @udid + UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator? + end + + # Open a URL and dismiss the iOS "Open in Yattee?" system confirmation dialog. + # The dialog is a system banner invisible to AXe (like the password save dialog), + # so we must dismiss it with coordinate taps. + # On iPhone 17 Pro (393x852pt), the "Open" button appears on the right side + # of a centered banner. The vertical position varies depending on content behind it. + # IMPORTANT: Tapping outside the dialog dismisses it as "Cancel", so we check + # whether the dialog is still showing (empty AXe tree) before each tap. + def open_url(url) + UITest::Simulator.open_url(@udid, url) + sleep 0.8 + + # "Open" button positions from highest to lowest - dialog position varies + # based on the content behind it (higher on Home, lower on pushed views) + open_button_positions = [ + { x: 280, y: 350 }, + { x: 290, y: 400 }, + { x: 290, y: 460 }, + { x: 280, y: 480 } + ] + + open_button_positions.each do |pos| + # Check if dialog is still blocking (AXe tree empty when system dialog is up) + tree = @axe.describe_ui + app_children = tree.is_a?(Array) ? tree.first&.dig('children') : nil + break if app_children && !app_children.empty? + + @axe.tap_coordinates(x: pos[:x], y: pos[:y]) + sleep 0.5 + end + end + + # Navigate back to Home tab between tests + def navigate_to_home + # Try edge swipes to pop any pushed views (up to 5 times) + 5.times do + break if @axe.element_exists?('home.settingsButton') + + @axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3) + sleep 0.5 + end + + # Fallback: tap Home tab bar item + unless @axe.element_exists?('home.settingsButton') + begin + @axe.tap_label('Home') + sleep 0.5 + rescue UITest::Axe::AxeError + # Already on Home or tab not found + end + end + end + + # Wait for text to become visible with polling + def wait_for_text(text, timeout: UITest::Config.element_timeout) + start_time = Time.now + + loop do + return true if @axe.text_visible?(text) + + if Time.now - start_time > timeout + @axe.screenshot("debug-wait-for-#{text.downcase.gsub(/\s+/, '-')}") + raise "Text '#{text}' not visible after #{timeout} seconds" + end + + sleep 0.5 + end + end + + # Wait for element to become visible with polling + def wait_for_element(identifier, timeout: UITest::Config.element_timeout) + start_time = Time.now + + loop do + return true if @axe.element_exists?(identifier) + + if Time.now - start_time > timeout + @axe.screenshot("debug-wait-for-#{identifier.gsub('.', '-')}") + raise "Element '#{identifier}' not found after #{timeout} seconds" + end + + sleep 0.5 + end + end + + describe 'yattee:// navigation URLs' do + after(:each) do + navigate_to_home + end + + it 'opens Playlists via yattee://playlists' do + open_url('yattee://playlists') + sleep 1 + wait_for_text('Playlists') + expect(@axe).to have_text('Playlists') + @axe.screenshot('url-scheme-playlists') + end + + it 'opens Bookmarks via yattee://bookmarks' do + open_url('yattee://bookmarks') + sleep 1 + wait_for_text('Bookmarks') + expect(@axe).to have_text('Bookmarks') + @axe.screenshot('url-scheme-bookmarks') + end + + it 'opens History via yattee://history' do + open_url('yattee://history') + sleep 1 + wait_for_text('History') + expect(@axe).to have_text('History') + @axe.screenshot('url-scheme-history') + end + + it 'opens Downloads via yattee://downloads' do + open_url('yattee://downloads') + sleep 1 + wait_for_text('Downloads') + expect(@axe).to have_text('Downloads') + @axe.screenshot('url-scheme-downloads') + end + + it 'opens Channels via yattee://channels' do + open_url('yattee://channels') + sleep 1 + wait_for_text('Channels') + expect(@axe).to have_text('Channels') + @axe.screenshot('url-scheme-channels') + end + + it 'opens Subscriptions via yattee://subscriptions' do + open_url('yattee://subscriptions') + sleep 1 + wait_for_text('Subscriptions') + expect(@axe).to have_text('Subscriptions') + @axe.screenshot('url-scheme-subscriptions') + end + + it 'opens Continue Watching via yattee://continue-watching' do + open_url('yattee://continue-watching') + sleep 1 + wait_for_text('Continue Watching') + expect(@axe).to have_text('Continue Watching') + @axe.screenshot('url-scheme-continue-watching') + end + + # NOTE: yattee://settings deep link is broken in the app - + # handlePendingNavigation pushes .settings onto homePath but it doesn't render. + # Skipping until the app's deep link handler is fixed to present Settings properly. + + it 'opens Search via yattee://search?q=test' do + open_url('yattee://search?q=test') + sleep 1 + # SearchView pushed via deep link has a sparse accessibility tree - + # only the nav bar (AXUniqueId "Search") is exposed, not its children. + # Verify navigation by checking the nav bar identifier. + wait_for_element('Search') + expect(@axe).to have_element('Search') + @axe.screenshot('url-scheme-search') + end + end +end + +RSpec.describe 'URL Schemes with Backend', :url_backend do + before(:all) do + skip 'Backend URL not configured (set YATTEE_SERVER_URL or INVIDIOUS_URL)' unless backend_available? + + @udid = UITest::Simulator.boot(UITest::Config.device) + + UITest::App.build( + device: UITest::Config.device, + skip: UITest::Config.skip_build? + ) + + UITest::App.install(udid: @udid) + UITest::App.launch(udid: @udid) + + sleep UITest::Config.app_launch_wait + + @axe = UITest::Axe.new(@udid) + @player = UITest::PlayerHelper.new(@axe) + @instance_setup = UITest::InstanceSetup.new(@axe) + + # Set up backend instance + setup_backend_instance + end + + after(:all) do + UITest::App.terminate(udid: @udid, silent: true) if @udid + UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator? + end + + def self.backend_available? + ENV['YATTEE_SERVER_URL'] || ENV['INVIDIOUS_URL'] + end + + def backend_available? + self.class.backend_available? + end + + def setup_backend_instance + if ENV['YATTEE_SERVER_URL'] + @instance_setup.ensure_yattee_server(ENV['YATTEE_SERVER_URL']) + elsif ENV['INVIDIOUS_URL'] + @instance_setup.ensure_invidious(ENV['INVIDIOUS_URL']) + end + end + + # Open a URL and dismiss the iOS "Open in Yattee?" system confirmation dialog + def open_url(url) + UITest::Simulator.open_url(@udid, url) + sleep 0.8 + + open_button_positions = [ + { x: 280, y: 350 }, + { x: 290, y: 400 }, + { x: 290, y: 460 }, + { x: 280, y: 480 } + ] + + open_button_positions.each do |pos| + tree = @axe.describe_ui + app_children = tree.is_a?(Array) ? tree.first&.dig('children') : nil + break if app_children && !app_children.empty? + + @axe.tap_coordinates(x: pos[:x], y: pos[:y]) + sleep 0.5 + end + end + + # Navigate back to Home tab + def navigate_to_home + 5.times do + break if @axe.element_exists?('home.settingsButton') + + @axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3) + sleep 0.5 + end + + unless @axe.element_exists?('home.settingsButton') + begin + @axe.tap_label('Home') + sleep 0.5 + rescue UITest::Axe::AxeError + # Already on Home + end + end + end + + # Wait for text with timeout + def wait_for_text(text, timeout: 15) + start_time = Time.now + + loop do + return true if @axe.text_visible?(text) + + if Time.now - start_time > timeout + @axe.screenshot("debug-backend-wait-#{text.downcase.gsub(/\s+/, '-')}") + raise "Text '#{text}' not visible after #{timeout} seconds" + end + + sleep 0.5 + end + end + + # Wait for any content to load (not an empty state) + def wait_for_content_loaded(timeout: 20) + start_time = Time.now + + loop do + # Content loaded if we see video titles, channel names, etc. + # Check that we're not just on an empty/loading screen + tree = @axe.describe_ui + tree_str = tree.to_s + + # Look for signs of loaded content (not just nav titles) + has_content = tree_str.include?('AXImage') || + tree_str.include?('AXStaticText') && tree_str.length > 2000 + + return true if has_content + + if Time.now - start_time > timeout + @axe.screenshot('debug-content-load-timeout') + raise "Content did not load after #{timeout} seconds" + end + + sleep 1 + end + end + + # Close player if open, then navigate home + def cleanup_after_video + if @player.player_expanded? + @player.close_player + sleep 0.5 + end + navigate_to_home + end + + describe 'yattee:// content URLs' do + after(:each) do + cleanup_after_video + end + + it 'opens video via yattee://video/{id}' do + open_url('yattee://video/dQw4w9WgXcQ') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-video') + end + + it 'opens channel via yattee://channel/{id}' do + open_url('yattee://channel/UC_x5XG1OV2P6uZZ5FSM9Ttw') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-channel') + end + + it 'opens playlist via yattee://playlist/{id}' do + open_url('yattee://playlist/PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-playlist') + end + end + + describe 'YouTube URL parsing' do + after(:each) do + cleanup_after_video + end + + it 'opens video via youtube.com/watch URL' do + open_url('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-youtube-watch') + end + + it 'opens video via youtu.be short URL' do + open_url('https://youtu.be/dQw4w9WgXcQ') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-youtube-short') + end + + it 'opens video via youtube.com/shorts URL' do + open_url('https://www.youtube.com/shorts/dQw4w9WgXcQ') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-youtube-shorts') + end + + it 'opens playlist via youtube.com/playlist URL' do + open_url('https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-youtube-playlist') + end + + it 'opens channel via youtube.com/@handle URL' do + open_url('https://www.youtube.com/@Google') + sleep 3 + wait_for_content_loaded + @axe.screenshot('url-scheme-youtube-handle') + end + end +end diff --git a/spec/ui/support/simulator.rb b/spec/ui/support/simulator.rb index bbf5a184..c9d29a56 100644 --- a/spec/ui/support/simulator.rb +++ b/spec/ui/support/simulator.rb @@ -35,6 +35,14 @@ module UITest udid end + # Open a URL in the simulator (triggers deep link handling) + # @param udid [String] UDID of the simulator + # @param url [String] URL to open + def open_url(udid, url) + output, status = Open3.capture2e('xcrun', 'simctl', 'openurl', udid, url) + raise SimulatorError, "openurl failed: #{output}" unless status.success? + end + # Shutdown a simulator by UDID # @param udid [String] UDID of the simulator def shutdown(udid)