Files
yattee/spec/ui/smoke/url_schemes_spec.rb
Arkadiusz Fal cae1226cfe 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).
2026-02-10 05:45:19 +01:00

393 lines
11 KiB
Ruby

# 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