Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,71 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'App Launch', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize AXe
@axe = UITest::Axe.new(@udid)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
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')
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')
end
it 'displays the Bookmarks card' do
expect(@axe).to have_element('library.card.bookmarks')
end
it 'displays the History card' do
expect(@axe).to have_element('library.card.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')
end
it 'matches the baseline screenshot', :visual do
screenshot = @axe.screenshot('app-launch-library')
expect(screenshot).to match_baseline
end
end
end

View File

@@ -0,0 +1,353 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Import Playlists from Piped', :smoke do
before(:all) do
skip 'Piped credentials not configured' unless UITest::Config.piped_credentials?
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
# Set up prerequisites: Yattee Server + logged-in Piped
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
@instance_setup.ensure_piped(UITest::Config.piped_url)
@instance_setup.ensure_piped_logged_in(UITest::Config.piped_host)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'Import section visibility' do
it 'shows Import section with Playlists link when logged in to Piped' do
# Navigate to Piped instance settings
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Verify Import section is visible with Playlists link
expect(@axe).to have_text('Import')
expect(@axe).to have_element('sources.import.playlists')
# Close settings
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
end
describe 'Import Playlists view' do
before do
# Navigate to Import Playlists
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Tap Playlists navigation link
@axe.tap_id('sources.import.playlists')
sleep 1
end
after do
# Navigate back and close settings
# Try back button or swipe
begin
@axe.tap_label('Back')
sleep 0.5
rescue UITest::Axe::AxeError
@axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
sleep 0.5
end
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
it 'displays Import Playlists view' do
expect(@axe).to have_element('import.playlists.view')
end
it 'loads playlists from Piped' do
# Wait for loading to complete
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for list by looking for row elements, or empty/error states
has_list = has_playlist_rows?
has_empty = @axe.element_exists?('import.playlists.empty')
has_error = @axe.element_exists?('import.playlists.error')
break if has_list || has_empty || has_error
raise 'Timeout waiting for playlists' if Time.now - start_time > 15
sleep 0.5
end
# Should show list or empty state (not loading)
has_list = has_playlist_rows?
has_empty = @axe.element_exists?('import.playlists.empty')
has_error = @axe.element_exists?('import.playlists.error')
# Either list or empty is success, error is acceptable but not ideal
expect(has_list || has_empty || has_error).to be true
end
it 'shows Add All button when there are unimported playlists' do
# Wait for list to load
wait_for_playlists_list
# Check if there are any unimported playlists (Add buttons visible)
# iOS 26 doesn't expose button IDs properly, so look for buttons with "Add" label
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
if add_button
# Add All button should be in toolbar - tap by coordinates (top-right)
# Toolbar buttons don't expose accessibility IDs on iOS 26
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear
expect(@axe).to have_text('Add All')
# Dismiss dialog by tapping outside or swiping down
@axe.tap_coordinates(x: 200, y: 300)
sleep 0.3
else
skip 'All playlists already imported - Add All button correctly hidden'
end
end
it 'can add individual playlist' do
wait_for_playlists_list
# Skip if no playlists or empty
skip 'No playlists to import' unless has_playlist_rows?
# Find first add button - iOS 26 doesn't expose button IDs, use label + coordinates
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unimported playlists' unless add_button
# Get button coordinates
frame = add_button['frame']
x = frame['x'] + (frame['width'] / 2)
y = frame['y'] + (frame['height'] / 2)
# Tap the add button
@axe.tap_coordinates(x: x, y: y)
sleep 0.5
# Either progress indicator appears or merge warning dialog appears
# Wait a bit for import to start
sleep 1
# Check if merge warning is shown (playlist already exists)
if @axe.text_visible?('Playlist Exists')
# Dismiss the merge warning
@axe.tap_label('Cancel')
sleep 0.5
# Test passes - merge warning shown correctly
else
# Wait for import to complete (progress indicator should disappear)
start_time = Time.now
loop do
# Check if import completed (no more progress indicators)
tree = @axe.describe_ui
has_progress = find_progress_indicator(tree)
break unless has_progress
raise 'Import timeout' if Time.now - start_time > 30
sleep 0.5
end
# The button should change to checkmark
new_tree = @axe.describe_ui
new_add_buttons = count_add_buttons(new_tree)
original_add_buttons = count_add_buttons(tree)
# Either we have fewer add buttons, or the button changed to imported indicator
expect(new_add_buttons).to be <= original_add_buttons
end
end
it 'shows confirmation dialog before Add All' do
wait_for_playlists_list
# Check if there are playlists to add
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unimported playlists - Add All not shown' unless add_button
# Tap Add All button in toolbar by coordinates (top-right area)
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear with "Add All" action
expect(@axe).to have_text('Add All')
end
end
private
def wait_for_playlists_list(timeout: 15)
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for rows or empty/error states instead
return if has_playlist_rows? ||
@axe.element_exists?('import.playlists.empty')
raise 'Timeout waiting for playlists' if Time.now - start_time > timeout
sleep 0.5
end
end
# Check if any playlist row elements are visible in the UI tree
# iOS 26 doesn't expose List container ID, but rows are visible
def has_playlist_rows?
tree = @axe.describe_ui
find_element_with_prefix(tree, 'import.playlists.row.')
end
def find_element_with_prefix(node, prefix)
case node
when Hash
id = node['AXUniqueId']
return true if id&.start_with?(prefix)
node.each_value do |value|
return true if find_element_with_prefix(value, prefix)
end
when Array
node.each do |item|
return true if find_element_with_prefix(item, prefix)
end
end
false
end
# Find an "Add" button element by its AXLabel (iOS 26 doesn't expose button IDs properly)
def find_add_button_element(node)
case node
when Hash
# Look for buttons with "Add" label that are individual add buttons (not Add All)
if node['role'] == 'AXButton' && node['AXLabel'] == 'Add' && node['frame']
return node
end
node.each_value do |value|
result = find_add_button_element(value)
return result if result
end
when Array
node.each do |item|
result = find_add_button_element(item)
return result if result
end
end
nil
end
# Find progress indicator (ProgressView) in the tree
def find_progress_indicator(node)
case node
when Hash
# ProgressView shows as AXProgressIndicator
return true if node['role'] == 'AXProgressIndicator'
node.each_value do |value|
return true if find_progress_indicator(value)
end
when Array
node.each do |item|
return true if find_progress_indicator(item)
end
end
false
end
# Count the number of "Add" buttons in the tree
def count_add_buttons(node, count = 0)
case node
when Hash
count += 1 if node['role'] == 'AXButton' && node['AXLabel'] == 'Add'
node.each_value do |value|
count = count_add_buttons(value, count)
end
when Array
node.each do |item|
count = count_add_buttons(item, count)
end
end
count
end
def find_first_add_button(node)
case node
when Hash
id = node['AXUniqueId']
return id if id&.start_with?('import.playlists.add.')
node.each_value do |value|
result = find_first_add_button(value)
return result if result
end
when Array
node.each do |item|
result = find_first_add_button(item)
return result if result
end
end
nil
end
end

View File

@@ -0,0 +1,353 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Import Playlists from Invidious', :smoke do
before(:all) do
skip 'Invidious credentials not configured' unless UITest::Config.invidious_credentials?
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
# Set up prerequisites: Yattee Server + logged-in Invidious
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
@instance_setup.ensure_invidious(UITest::Config.invidious_url)
@instance_setup.ensure_invidious_logged_in(UITest::Config.invidious_host)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'Import section visibility' do
it 'shows Import section with Playlists link when logged in to Invidious' do
# Navigate to Invidious instance settings
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Verify Import section is visible with Playlists link
expect(@axe).to have_text('Import')
expect(@axe).to have_element('sources.import.playlists')
# Close settings
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
end
describe 'Import Playlists view' do
before do
# Navigate to Import Playlists
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Tap Playlists navigation link
@axe.tap_id('sources.import.playlists')
sleep 1
end
after do
# Navigate back and close settings
# Try back button or swipe
begin
@axe.tap_label('Back')
sleep 0.5
rescue UITest::Axe::AxeError
@axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
sleep 0.5
end
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
it 'displays Import Playlists view' do
expect(@axe).to have_element('import.playlists.view')
end
it 'loads playlists from Invidious' do
# Wait for loading to complete
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for list by looking for row elements, or empty/error states
has_list = has_playlist_rows?
has_empty = @axe.element_exists?('import.playlists.empty')
has_error = @axe.element_exists?('import.playlists.error')
break if has_list || has_empty || has_error
raise 'Timeout waiting for playlists' if Time.now - start_time > 15
sleep 0.5
end
# Should show list or empty state (not loading)
has_list = has_playlist_rows?
has_empty = @axe.element_exists?('import.playlists.empty')
has_error = @axe.element_exists?('import.playlists.error')
# Either list or empty is success, error is acceptable but not ideal
expect(has_list || has_empty || has_error).to be true
end
it 'shows Add All button when there are unimported playlists' do
# Wait for list to load
wait_for_playlists_list
# Check if there are any unimported playlists (Add buttons visible)
# iOS 26 doesn't expose button IDs properly, so look for buttons with "Add" label
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
if add_button
# Add All button should be in toolbar - tap by coordinates (top-right)
# Toolbar buttons don't expose accessibility IDs on iOS 26
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear
expect(@axe).to have_text('Add All')
# Dismiss dialog by tapping outside or swiping down
@axe.tap_coordinates(x: 200, y: 300)
sleep 0.3
else
skip 'All playlists already imported - Add All button correctly hidden'
end
end
it 'can add individual playlist' do
wait_for_playlists_list
# Skip if no playlists or empty
skip 'No playlists to import' unless has_playlist_rows?
# Find first add button - iOS 26 doesn't expose button IDs, use label + coordinates
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unimported playlists' unless add_button
# Get button coordinates
frame = add_button['frame']
x = frame['x'] + (frame['width'] / 2)
y = frame['y'] + (frame['height'] / 2)
# Tap the add button
@axe.tap_coordinates(x: x, y: y)
sleep 0.5
# Either progress indicator appears or merge warning dialog appears
# Wait a bit for import to start
sleep 1
# Check if merge warning is shown (playlist already exists)
if @axe.text_visible?('Playlist Exists')
# Dismiss the merge warning
@axe.tap_label('Cancel')
sleep 0.5
# Test passes - merge warning shown correctly
else
# Wait for import to complete (progress indicator should disappear)
start_time = Time.now
loop do
# Check if import completed (no more progress indicators)
tree = @axe.describe_ui
has_progress = find_progress_indicator(tree)
break unless has_progress
raise 'Import timeout' if Time.now - start_time > 30
sleep 0.5
end
# The button should change to checkmark
new_tree = @axe.describe_ui
new_add_buttons = count_add_buttons(new_tree)
original_add_buttons = count_add_buttons(tree)
# Either we have fewer add buttons, or the button changed to imported indicator
expect(new_add_buttons).to be <= original_add_buttons
end
end
it 'shows confirmation dialog before Add All' do
wait_for_playlists_list
# Check if there are playlists to add
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unimported playlists - Add All not shown' unless add_button
# Tap Add All button in toolbar by coordinates (top-right area)
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear with "Add All" action
expect(@axe).to have_text('Add All')
end
end
private
def wait_for_playlists_list(timeout: 15)
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for rows or empty/error states instead
return if has_playlist_rows? ||
@axe.element_exists?('import.playlists.empty')
raise 'Timeout waiting for playlists' if Time.now - start_time > timeout
sleep 0.5
end
end
# Check if any playlist row elements are visible in the UI tree
# iOS 26 doesn't expose List container ID, but rows are visible
def has_playlist_rows?
tree = @axe.describe_ui
find_element_with_prefix(tree, 'import.playlists.row.')
end
def find_element_with_prefix(node, prefix)
case node
when Hash
id = node['AXUniqueId']
return true if id&.start_with?(prefix)
node.each_value do |value|
return true if find_element_with_prefix(value, prefix)
end
when Array
node.each do |item|
return true if find_element_with_prefix(item, prefix)
end
end
false
end
# Find an "Add" button element by its AXLabel (iOS 26 doesn't expose button IDs properly)
def find_add_button_element(node)
case node
when Hash
# Look for buttons with "Add" label that are individual add buttons (not Add All)
if node['role'] == 'AXButton' && node['AXLabel'] == 'Add' && node['frame']
return node
end
node.each_value do |value|
result = find_add_button_element(value)
return result if result
end
when Array
node.each do |item|
result = find_add_button_element(item)
return result if result
end
end
nil
end
# Find progress indicator (ProgressView) in the tree
def find_progress_indicator(node)
case node
when Hash
# ProgressView shows as AXProgressIndicator
return true if node['role'] == 'AXProgressIndicator'
node.each_value do |value|
return true if find_progress_indicator(value)
end
when Array
node.each do |item|
return true if find_progress_indicator(item)
end
end
false
end
# Count the number of "Add" buttons in the tree
def count_add_buttons(node, count = 0)
case node
when Hash
count += 1 if node['role'] == 'AXButton' && node['AXLabel'] == 'Add'
node.each_value do |value|
count = count_add_buttons(value, count)
end
when Array
node.each do |item|
count = count_add_buttons(item, count)
end
end
count
end
def find_first_add_button(node)
case node
when Hash
id = node['AXUniqueId']
return id if id&.start_with?('import.playlists.add.')
node.each_value do |value|
result = find_first_add_button(value)
return result if result
end
when Array
node.each do |item|
result = find_first_add_button(item)
return result if result
end
end
nil
end
end

View File

@@ -0,0 +1,313 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Import Subscriptions from Piped', :smoke do
before(:all) do
skip 'Piped credentials not configured' unless UITest::Config.piped_credentials?
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
# Set up prerequisites: Yattee Server + logged-in Piped
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
@instance_setup.ensure_piped(UITest::Config.piped_url)
@instance_setup.ensure_piped_logged_in(UITest::Config.piped_host)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'Import section visibility' do
it 'shows Import section when Yattee Server exists and logged in to Piped' do
# Navigate to Piped instance settings
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Verify Import section is visible
expect(@axe).to have_text('Import')
expect(@axe).to have_element('sources.import.subscriptions')
# Close settings
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
end
describe 'Import Subscriptions view' do
before do
# Navigate to Import Subscriptions
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Tap Subscriptions navigation link
@axe.tap_id('sources.import.subscriptions')
sleep 1
end
after do
# Navigate back and close settings
# Try back button or swipe
begin
@axe.tap_label('Back')
sleep 0.5
rescue UITest::Axe::AxeError
@axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
sleep 0.5
end
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
it 'displays Import Subscriptions view' do
expect(@axe).to have_element('import.subscriptions.view')
end
it 'loads subscriptions from Piped' do
# Wait for loading to complete
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for list by looking for row elements, or empty/error states
has_list = has_subscription_rows?
has_empty = @axe.element_exists?('import.subscriptions.empty')
has_error = @axe.element_exists?('import.subscriptions.error')
break if has_list || has_empty || has_error
raise 'Timeout waiting for subscriptions' if Time.now - start_time > 15
sleep 0.5
end
# Should show list or empty state (not loading)
has_list = has_subscription_rows?
has_empty = @axe.element_exists?('import.subscriptions.empty')
has_error = @axe.element_exists?('import.subscriptions.error')
# Either list or empty is success, error is acceptable but not ideal
expect(has_list || has_empty || has_error).to be true
end
it 'shows Add All button when there are unsubscribed channels' do
# Wait for list to load
wait_for_subscriptions_list
# Check if there are any unsubscribed channels (Add buttons visible)
# iOS 26 doesn't expose button IDs properly, so look for buttons with "Add" label
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
if add_button
# Add All button should be in toolbar - tap by coordinates (top-right)
# Toolbar buttons don't expose accessibility IDs on iOS 26
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear
expect(@axe).to have_text('Add All')
# Dismiss dialog by tapping outside or swiping down
@axe.tap_coordinates(x: 200, y: 300)
sleep 0.3
else
skip 'All channels already subscribed - Add All button correctly hidden'
end
end
it 'can add individual subscription' do
wait_for_subscriptions_list
# Skip if no subscriptions or empty
skip 'No subscriptions to import' unless has_subscription_rows?
# Find first add button - iOS 26 doesn't expose button IDs, use label + coordinates
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unsubscribed channels' unless add_button
# Get button coordinates
frame = add_button['frame']
x = frame['x'] + (frame['width'] / 2)
y = frame['y'] + (frame['height'] / 2)
# Tap the add button
@axe.tap_coordinates(x: x, y: y)
sleep 1
# The button should change - verify by checking that same position now shows checkmark
# or that there's one fewer Add button
new_tree = @axe.describe_ui
new_add_buttons = count_add_buttons(new_tree)
original_add_buttons = count_add_buttons(tree)
# Either we have fewer add buttons, or the button changed to subscribed indicator
expect(new_add_buttons).to be < original_add_buttons
end
it 'shows confirmation dialog before Add All' do
wait_for_subscriptions_list
# Check if there are channels to add
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unsubscribed channels - Add All not shown' unless add_button
# Tap Add All button in toolbar by coordinates (top-right area)
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear with "Add All" action
expect(@axe).to have_text('Add All')
end
end
private
def wait_for_subscriptions_list(timeout: 15)
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for rows or empty/error states instead
return if has_subscription_rows? ||
@axe.element_exists?('import.subscriptions.empty')
raise 'Timeout waiting for subscriptions' if Time.now - start_time > timeout
sleep 0.5
end
end
# Check if any subscription row elements are visible in the UI tree
# iOS 26 doesn't expose List container ID, but rows are visible
def has_subscription_rows?
tree = @axe.describe_ui
find_element_with_prefix(tree, 'import.subscriptions.row.')
end
def find_element_with_prefix(node, prefix)
case node
when Hash
id = node['AXUniqueId']
return true if id&.start_with?(prefix)
node.each_value do |value|
return true if find_element_with_prefix(value, prefix)
end
when Array
node.each do |item|
return true if find_element_with_prefix(item, prefix)
end
end
false
end
# Find an "Add" button element by its AXLabel (iOS 26 doesn't expose button IDs properly)
def find_add_button_element(node)
case node
when Hash
# Look for buttons with "Add" label that are individual add buttons (not Add All)
if node['role'] == 'AXButton' && node['AXLabel'] == 'Add' && node['frame']
return node
end
node.each_value do |value|
result = find_add_button_element(value)
return result if result
end
when Array
node.each do |item|
result = find_add_button_element(item)
return result if result
end
end
nil
end
# Count the number of "Add" buttons in the tree
def count_add_buttons(node, count = 0)
case node
when Hash
if node['role'] == 'AXButton' && node['AXLabel'] == 'Add'
count += 1
end
node.each_value do |value|
count = count_add_buttons(value, count)
end
when Array
node.each do |item|
count = count_add_buttons(item, count)
end
end
count
end
def find_first_add_button(node)
case node
when Hash
id = node['AXUniqueId']
return id if id&.start_with?('import.subscriptions.add.')
node.each_value do |value|
result = find_first_add_button(value)
return result if result
end
when Array
node.each do |item|
result = find_first_add_button(item)
return result if result
end
end
nil
end
end

View File

@@ -0,0 +1,315 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Import Subscriptions from Invidious', :smoke do
before(:all) do
skip 'Invidious credentials not configured' unless UITest::Config.invidious_credentials?
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
# Set up prerequisites: Yattee Server + logged-in Invidious
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
@instance_setup.ensure_invidious(UITest::Config.invidious_url)
@instance_setup.ensure_invidious_logged_in(UITest::Config.invidious_host)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'Import section visibility' do
it 'shows Import section when Yattee Server exists and logged in to Invidious' do
# Navigate to Invidious instance settings
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Verify Import section is visible
expect(@axe).to have_text('Import')
expect(@axe).to have_element('sources.import.subscriptions')
# Close settings
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
end
describe 'Import Subscriptions view' do
before do
# Navigate to Import Subscriptions
@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}")
sleep 0.8
# Wait for EditSourceView
start_time = Time.now
loop do
break if @axe.element_exists?('editSource.view')
raise 'EditSourceView not found' if Time.now - start_time > 10
sleep 0.3
end
# Tap Subscriptions navigation link
@axe.tap_id('sources.import.subscriptions')
sleep 1
end
after do
# Navigate back and close settings
# Try back button or swipe
begin
@axe.tap_label('Back')
sleep 0.5
rescue UITest::Axe::AxeError
@axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
sleep 0.5
end
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
end
it 'displays Import Subscriptions view' do
expect(@axe).to have_element('import.subscriptions.view')
end
it 'loads subscriptions from Invidious' do
# Wait for loading to complete
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for list by looking for row elements, or empty/error states
has_list = has_subscription_rows?
has_empty = @axe.element_exists?('import.subscriptions.empty')
has_error = @axe.element_exists?('import.subscriptions.error')
break if has_list || has_empty || has_error
if Time.now - start_time > 15
raise 'Timeout waiting for subscriptions'
end
sleep 0.5
end
# Should show list or empty state (not loading)
has_list = has_subscription_rows?
has_empty = @axe.element_exists?('import.subscriptions.empty')
has_error = @axe.element_exists?('import.subscriptions.error')
# Either list or empty is success, error is acceptable but not ideal
expect(has_list || has_empty || has_error).to be true
end
it 'shows Add All button when there are unsubscribed channels' do
# Wait for list to load
wait_for_subscriptions_list
# Check if there are any unsubscribed channels (Add buttons visible)
# iOS 26 doesn't expose button IDs properly, so look for buttons with "Add" label
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
if add_button
# Add All button should be in toolbar - tap by coordinates (top-right)
# Toolbar buttons don't expose accessibility IDs on iOS 26
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear
expect(@axe).to have_text('Add All')
# Dismiss dialog by tapping outside or swiping down
@axe.tap_coordinates(x: 200, y: 300)
sleep 0.3
else
skip 'All channels already subscribed - Add All button correctly hidden'
end
end
it 'can add individual subscription' do
wait_for_subscriptions_list
# Skip if no subscriptions or empty
skip 'No subscriptions to import' unless has_subscription_rows?
# Find first add button - iOS 26 doesn't expose button IDs, use label + coordinates
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unsubscribed channels' unless add_button
# Get button coordinates
frame = add_button['frame']
x = frame['x'] + (frame['width'] / 2)
y = frame['y'] + (frame['height'] / 2)
# Tap the add button
@axe.tap_coordinates(x: x, y: y)
sleep 1
# The button should change - verify by checking that same position now shows checkmark
# or that there's one fewer Add button
new_tree = @axe.describe_ui
new_add_buttons = count_add_buttons(new_tree)
original_add_buttons = count_add_buttons(tree)
# Either we have fewer add buttons, or the button changed to subscribed indicator
expect(new_add_buttons).to be < original_add_buttons
end
it 'shows confirmation dialog before Add All' do
wait_for_subscriptions_list
# Check if there are channels to add
tree = @axe.describe_ui
add_button = find_add_button_element(tree)
skip 'No unsubscribed channels - Add All not shown' unless add_button
# Tap Add All button in toolbar by coordinates (top-right area)
@axe.tap_coordinates(x: 370, y: 105)
sleep 0.5
# Confirmation dialog should appear with "Add All" action
expect(@axe).to have_text('Add All')
end
end
private
def wait_for_subscriptions_list(timeout: 15)
start_time = Time.now
loop do
# iOS 26 doesn't expose List's accessibilityIdentifier properly
# Check for rows or empty/error states instead
return if has_subscription_rows? ||
@axe.element_exists?('import.subscriptions.empty')
raise 'Timeout waiting for subscriptions' if Time.now - start_time > timeout
sleep 0.5
end
end
# Check if any subscription row elements are visible in the UI tree
# iOS 26 doesn't expose List container ID, but rows are visible
def has_subscription_rows?
tree = @axe.describe_ui
find_element_with_prefix(tree, 'import.subscriptions.row.')
end
def find_element_with_prefix(node, prefix)
case node
when Hash
id = node['AXUniqueId']
return true if id&.start_with?(prefix)
node.each_value do |value|
return true if find_element_with_prefix(value, prefix)
end
when Array
node.each do |item|
return true if find_element_with_prefix(item, prefix)
end
end
false
end
# Find an "Add" button element by its AXLabel (iOS 26 doesn't expose button IDs properly)
def find_add_button_element(node)
case node
when Hash
# Look for buttons with "Add" label that are individual add buttons (not Add All)
if node['role'] == 'AXButton' && node['AXLabel'] == 'Add' && node['frame']
return node
end
node.each_value do |value|
result = find_add_button_element(value)
return result if result
end
when Array
node.each do |item|
result = find_add_button_element(item)
return result if result
end
end
nil
end
# Count the number of "Add" buttons in the tree
def count_add_buttons(node, count = 0)
case node
when Hash
if node['role'] == 'AXButton' && node['AXLabel'] == 'Add'
count += 1
end
node.each_value do |value|
count = count_add_buttons(value, count)
end
when Array
node.each do |item|
count = count_add_buttons(item, count)
end
end
count
end
def find_first_add_button(node)
case node
when Hash
id = node['AXUniqueId']
return id if id&.start_with?('import.subscriptions.add.')
node.each_value do |value|
result = find_first_add_button(value)
return result if result
end
when Array
node.each do |item|
result = find_first_add_button(item)
return result if result
end
end
nil
end
end

View File

@@ -0,0 +1,73 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Invidious Instance', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize AXe and InstanceSetup helper
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'adding via Detect & Add' do
it 'adds Invidious instance and verifies it appears in Sources' 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')
# 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')
sleep 1
expect(@axe).to have_element('settings.view')
# Navigate to Sources
@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}")
# Close settings
begin
@axe.tap_label('Done')
rescue StandardError
nil
end
sleep 0.5
end
end
end

View File

@@ -0,0 +1,73 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Piped Instance', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize AXe and InstanceSetup helper
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'adding via Detect & Add' do
it 'adds Piped instance and verifies it appears in Sources' 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')
# 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')
sleep 1
expect(@axe).to have_element('settings.view')
# Navigate to Sources
@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}")
# Close settings
begin
@axe.tap_label('Done')
rescue StandardError
nil
end
sleep 0.5
end
end
end

View File

@@ -0,0 +1,47 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Player Controls Preview', :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
describe 'preview padding comparison' do
it 'captures Portrait and Landscape screenshots for comparison' do
# Navigate to Settings tab
@axe.tap_label('Settings')
sleep 1
# Navigate to Player Controls
@axe.tap_label('Player Controls')
sleep 1
# Capture Portrait screenshot (default)
portrait_path = @axe.screenshot('player_controls_portrait')
puts "Portrait screenshot: #{portrait_path}"
# Switch to Landscape preview by tapping the right side of the segmented control
# The picker is at x=48, width=306, so Landscape segment is around x=280, y=398
@axe.tap_coordinates(x: 280, y: 398)
sleep 0.5
# Capture Landscape screenshot
landscape_path = @axe.screenshot('player_controls_landscape')
puts "Landscape screenshot: #{landscape_path}"
puts "\nScreenshots saved to: #{UITest::Config.current_dir}"
puts 'Compare these screenshots to verify padding consistency.'
end
end
end

View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Search', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
@search_helper = UITest::SearchHelper.new(@axe)
# Ensure Yattee Server instance is configured
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'searching for videos' do
it 'navigates to search, enters query, and displays results' do
video_id = 'XfELJU1mRMg'
# Navigate to Search tab
@search_helper.navigate_to_search
expect(@search_helper.search_visible?).to be true
# Search for the known video ID
@search_helper.search(video_id)
# Wait for results view to appear (filter strip + results container)
@search_helper.wait_for_results
# Verify results are displayed
expect(@search_helper.results_visible?).to be true
# Take a screenshot for visual verification
# Note: Individual video rows aren't exposed in iOS accessibility tree
# due to ScrollView/LazyVStack limitations
@axe.screenshot('search_results_final')
end
end
end

View File

@@ -0,0 +1,107 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Settings', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize AXe
@axe = UITest::Axe.new(@udid)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'opening Settings from Library' 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')
# Tap Settings button using accessibility identifier
@axe.tap_id('library.settingsButton')
sleep 1
end
after(:all) do
# Close Settings to return to Library
begin
@axe.tap_label('Done')
rescue StandardError
nil
end
sleep 0.5
end
it 'opens the Settings view' do
expect(@axe).to have_element('settings.view')
end
it 'displays the Settings title' do
expect(@axe).to have_text('Settings')
end
it 'displays the Done button' do
expect(@axe).to have_element('settings.doneButton')
end
it 'displays the Sources section' do
expect(@axe).to have_text('Sources')
end
it 'displays the Playback section' do
expect(@axe).to have_text('Playback')
end
it 'displays the Appearance section' do
expect(@axe).to have_text('Appearance')
end
it 'matches the baseline screenshot', :visual do
screenshot = @axe.screenshot('settings-main')
expect(screenshot).to match_baseline
end
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
end
end
it 'closes Settings when tapping Done' do
# Verify we're in Settings
expect(@axe).to have_element('settings.view')
# Tap Done
@axe.tap_label('Done')
sleep 0.5
# Verify we're back on Library (check for text since inlineLarge title has no AXUniqueId)
expect(@axe).to have_text('Library')
expect(@axe).not_to have_element('settings.view')
end
end
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Video Playback with Invidious', :smoke do
before(:all) do
# Skip if Invidious URL not explicitly configured
skip 'Invidious URL not configured (set INVIDIOUS_URL env var)' unless ENV['INVIDIOUS_URL']
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
@search_helper = UITest::SearchHelper.new(@axe)
@player_helper = UITest::PlayerHelper.new(@axe)
# Remove Yattee Server if exists (to ensure searches use Invidious)
@instance_setup.remove_yattee_server(UITest::Config.yattee_server_host)
# Ensure Invidious instance is configured
@instance_setup.ensure_invidious(UITest::Config.invidious_url)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'playing video from search' do
it 'searches, taps video, plays, and closes player' do
video_id = 'XfELJU1mRMg'
# Navigate to Search and find video
@search_helper.navigate_to_search
@search_helper.search(video_id)
@search_helper.wait_for_results
# Verify results view is displayed
expect(@search_helper.results_visible?).to be true
# Tap first video result thumbnail to start playback directly
@search_helper.tap_first_result_thumbnail
# Wait for player to expand and start playing
@player_helper.wait_for_player_expanded
@player_helper.wait_for_playback_started
# Take screenshot of playback for visual verification
@axe.screenshot('video_playback_invidious_playing')
# Close the player
@player_helper.close_player
# Take screenshot after close attempt
@axe.screenshot('video_playback_invidious_after_close')
end
end
end

View File

@@ -0,0 +1,79 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Video Playback with Piped', :smoke do
before(:all) do
# Skip if Piped URL not explicitly configured
skip 'Piped URL not configured (set PIPED_URL env var)' unless ENV['PIPED_URL']
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
@search_helper = UITest::SearchHelper.new(@axe)
@player_helper = UITest::PlayerHelper.new(@axe)
# Remove Yattee Server if exists (to ensure searches use Piped)
@instance_setup.remove_yattee_server(UITest::Config.yattee_server_host)
# Remove Invidious if exists (to ensure Piped is the only backend)
@instance_setup.remove_invidious(UITest::Config.invidious_host)
# Ensure Piped instance is configured
@instance_setup.ensure_piped(UITest::Config.piped_url)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'playing video from search' do
it 'searches, taps video, plays, and closes player' do
video_id = 'XfELJU1mRMg'
# Navigate to Search and find video
@search_helper.navigate_to_search
@search_helper.search(video_id)
@search_helper.wait_for_results
# Verify results view is displayed
expect(@search_helper.results_visible?).to be true
# Tap first video result thumbnail to start playback directly
@search_helper.tap_first_result_thumbnail
# Wait for player to expand and start playing
@player_helper.wait_for_player_expanded
@player_helper.wait_for_playback_started
# Take screenshot of playback for visual verification
@axe.screenshot('video_playback_piped_playing')
# Close the player
@player_helper.close_player
# Take screenshot after close attempt
@axe.screenshot('video_playback_piped_after_close')
end
end
end

View File

@@ -0,0 +1,71 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Video Playback', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize helpers
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
@search_helper = UITest::SearchHelper.new(@axe)
@player_helper = UITest::PlayerHelper.new(@axe)
# Ensure Yattee Server instance is configured
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'playing video from search' do
it 'searches, taps video, plays, and closes player' do
video_id = 'XfELJU1mRMg'
# Navigate to Search and find video
@search_helper.navigate_to_search
@search_helper.search(video_id)
@search_helper.wait_for_results
# Verify results view is displayed
expect(@search_helper.results_visible?).to be true
# Tap first video result thumbnail to start playback directly
# Note: Tapping thumbnail plays video, tapping text area opens info
@search_helper.tap_first_result_thumbnail
# Wait for player to expand and start playing
@player_helper.wait_for_player_expanded
@player_helper.wait_for_playback_started
# Take screenshot of playback for visual verification
@axe.screenshot('video_playback_playing')
# Close the player
@player_helper.close_player
# Take screenshot after close attempt
@axe.screenshot('video_playback_after_close')
end
end
end

View File

@@ -0,0 +1,73 @@
# frozen_string_literal: true
require_relative '../spec_helper'
RSpec.describe 'Yattee Server Instance', :smoke do
before(:all) do
# Boot simulator
@udid = UITest::Simulator.boot(UITest::Config.device)
# Build app (unless skipped)
UITest::App.build(
device: UITest::Config.device,
skip: UITest::Config.skip_build?
)
# Install and launch
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
# Wait for app to stabilize
sleep UITest::Config.app_launch_wait
# Initialize AXe and InstanceSetup helper
@axe = UITest::Axe.new(@udid)
@instance_setup = UITest::InstanceSetup.new(@axe)
end
after(:all) do
# Terminate app
UITest::App.terminate(udid: @udid, silent: true) if @udid
# Shutdown simulator unless --keep-simulator
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'adding via Detect & Add' do
it 'adds Yattee Server instance and verifies it appears in Sources' 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')
# 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')
sleep 1
expect(@axe).to have_element('settings.view')
# Navigate to Sources
@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}")
# Close settings
begin
@axe.tap_label('Done')
rescue StandardError
nil
end
sleep 0.5
end
end
end

74
spec/ui/spec_helper.rb Normal file
View File

@@ -0,0 +1,74 @@
# frozen_string_literal: true
require 'rspec'
require 'fileutils'
require 'dotenv'
# Load environment variables from .env file (if present)
Dotenv.load
# Load support files
Dir[File.join(__dir__, 'support', '*.rb')].each { |f| require f }
# Load shared contexts
Dir[File.join(__dir__, 'support', 'shared_contexts', '*.rb')].each { |f| require f }
# Include custom matchers
RSpec.configure do |config|
config.include UITest::Matchers
# Use documentation format for better output
config.formatter = :documentation
# Run tests in random order to surface dependencies
config.order = :defined # Use defined order for UI tests (they may have dependencies)
# Show full backtrace on failure
config.full_backtrace = false
# Filter stack traces to remove gem noise
config.filter_gems_from_backtrace 'rspec-core', 'rspec-expectations', 'rspec-mocks', 'rspec-support'
# Retry configuration for flaky UI tests
config.around(:each, :retry) do |example|
example.run_with_retry(retry: 2, retry_wait: 1)
end
# Hooks for visual tests
config.before(:each, :visual) do
UITest::Config.ensure_directories!
end
# Global setup - ensure clean state
config.before(:suite) do
puts ''
puts '=' * 60
puts 'Yattee UI Tests'
puts '=' * 60
puts "Device: #{UITest::Config.device}"
puts "Generate baseline: #{UITest::Config.generate_baseline?}"
puts "Skip build: #{UITest::Config.skip_build?}"
puts "Keep app data: #{UITest::Config.keep_app_data?}"
puts '=' * 60
puts ''
end
# Global teardown
config.after(:suite) do
puts ''
puts '=' * 60
puts 'UI Tests Complete'
puts '=' * 60
end
end
# RSpec retry gem configuration (if available)
begin
require 'rspec/retry'
RSpec.configure do |config|
config.verbose_retry = true
config.display_try_failure_messages = true
end
rescue LoadError
# rspec-retry not installed, skip
end

120
spec/ui/support/app.rb Normal file
View File

@@ -0,0 +1,120 @@
# frozen_string_literal: true
require 'open3'
module UITest
# Manages app build, install, and launch lifecycle
class App
class AppError < StandardError; end
class << self
# Build the app for simulator
# @param device [String] Device name for destination
# @param skip [Boolean] Skip build if true
def build(device:, skip: false)
if skip
puts 'Skipping build (--skip-build)'
validate_app_exists!
return
end
puts "Building Yattee for #{device}..."
args = [
'xcodebuild',
'-project', Config.xcodeproj,
'-scheme', Config.scheme,
'-configuration', Config.configuration,
'-destination', "platform=iOS Simulator,name=#{device}",
'-derivedDataPath', Config.derived_data_path,
'build'
]
# Run build and capture output
output, status = Open3.capture2e(*args)
unless status.success?
# Extract relevant error lines
error_lines = output.lines.select { |l| l.include?('error:') }.join
raise AppError, "Build failed:\n#{error_lines.empty? ? output.last(2000) : error_lines}"
end
puts 'Build succeeded'
validate_app_exists!
end
# Install app to simulator
# @param udid [String] UDID of the simulator
# By default, uninstalls first to reset app data (use --keep-app-data to skip)
def install(udid:)
validate_app_exists!
# Uninstall first to reset app data (unless --keep-app-data)
unless Config.keep_app_data?
puts 'Resetting app data (uninstalling previous install)...'
uninstall(udid: udid)
# Reset keychain to prevent "Save Password?" dialogs during tests
puts 'Resetting simulator keychain...'
reset_keychain(udid: udid)
end
puts 'Installing app...'
output, status = Open3.capture2e('xcrun', 'simctl', 'install', udid, Config.app_path)
raise AppError, "Install failed: #{output}" unless status.success?
puts 'App installed'
end
# Launch app on simulator
# @param udid [String] UDID of the simulator
def launch(udid:)
puts 'Launching app...'
# Terminate if already running
terminate(udid: udid, silent: true)
output, status = Open3.capture2e('xcrun', 'simctl', 'launch', udid, Config.bundle_id)
raise AppError, "Launch failed: #{output}" unless status.success?
puts 'App launched'
end
# Terminate app on simulator
# @param udid [String] UDID of the simulator
# @param silent [Boolean] Don't raise error if app not running
def terminate(udid:, silent: false)
output, status = Open3.capture2e('xcrun', 'simctl', 'terminate', udid, Config.bundle_id)
# simctl terminate returns non-zero if app isn't running
return if silent
raise AppError, "Terminate failed: #{output}" unless status.success?
end
# Uninstall app from simulator
# @param udid [String] UDID of the simulator
def uninstall(udid:)
Open3.capture2e('xcrun', 'simctl', 'uninstall', udid, Config.bundle_id)
# Ignore errors - app might not be installed
end
# Reset simulator keychain to prevent "Save Password?" dialogs
# @param udid [String] UDID of the simulator
def reset_keychain(udid:)
Open3.capture2e('xcrun', 'simctl', 'keychain', udid, 'reset')
# Ignore errors - reset is best effort
end
private
def validate_app_exists!
return if File.exist?(Config.app_path)
raise AppError, "App not found at #{Config.app_path}. Run build first or remove --skip-build"
end
end
end
end

210
spec/ui/support/axe.rb Normal file
View File

@@ -0,0 +1,210 @@
# frozen_string_literal: true
require 'open3'
require 'json'
require 'fileutils'
module UITest
# Wrapper for AXe CLI tool for iOS Simulator automation
class Axe
class AxeError < StandardError; end
attr_reader :udid
def initialize(udid)
@udid = udid
end
# Get the full accessibility UI tree as parsed JSON
# @return [Hash] Parsed accessibility tree
def describe_ui
output, status = run_axe('describe-ui')
raise AxeError, "describe-ui failed: #{output}" unless status.success?
JSON.parse(output)
rescue JSON::ParserError => e
raise AxeError, "Failed to parse accessibility tree: #{e.message}"
end
# Check if an element with the given accessibility identifier exists
# @param identifier [String] Accessibility identifier to find
# @return [Boolean] true if element exists
def element_exists?(identifier)
tree = describe_ui
find_element_in_tree(tree, identifier: identifier).present?
rescue AxeError
false
end
# Find an element by accessibility identifier
# @param identifier [String] Accessibility identifier
# @return [Hash, nil] Element data or nil if not found
def find_element(identifier)
tree = describe_ui
find_element_in_tree(tree, identifier: identifier)
end
# Check if text is visible anywhere in the accessibility tree
# @param text [String] Text to search for
# @return [Boolean] true if text is visible
def text_visible?(text)
tree = describe_ui
find_element_in_tree(tree, label: text).present?
rescue AxeError
false
end
# Tap on an element by accessibility identifier
# @param identifier [String] Accessibility identifier
def tap_id(identifier)
output, status = run_axe('tap', '--id', identifier)
raise AxeError, "tap failed: #{output}" unless status.success?
end
# Tap on an element by accessibility label
# @param label [String] Accessibility label
def tap_label(label)
output, status = run_axe('tap', '--label', label)
raise AxeError, "tap failed: #{output}" unless status.success?
end
# Tap at specific coordinates
# @param x [Integer] X coordinate
# @param y [Integer] Y coordinate
def tap_coordinates(x:, y:)
output, status = run_axe('tap', '-x', x.to_s, '-y', y.to_s)
raise AxeError, "tap failed: #{output}" unless status.success?
end
# Perform a swipe gesture
# @param start_x [Integer] Starting X coordinate
# @param start_y [Integer] Starting Y coordinate
# @param end_x [Integer] Ending X coordinate
# @param end_y [Integer] Ending Y coordinate
# @param duration [Float] Duration in seconds (optional)
def swipe(start_x:, start_y:, end_x:, end_y:, duration: nil)
args = ['swipe', '--start-x', start_x.to_s, '--start-y', start_y.to_s,
'--end-x', end_x.to_s, '--end-y', end_y.to_s]
args += ['--duration', duration.to_s] if duration
output, status = run_axe(*args)
raise AxeError, "swipe failed: #{output}" unless status.success?
end
# Perform a preset gesture
# @param preset [String] Gesture preset (scroll-up, scroll-down, etc.)
def gesture(preset)
output, status = run_axe('gesture', preset)
raise AxeError, "gesture failed: #{output}" unless status.success?
end
# Type text
# @param text [String] Text to type
def type(text)
output, status = Open3.capture2e('axe', 'type', '--stdin', '--udid', @udid, stdin_data: text)
raise AxeError, "type failed: #{output}" unless status.success?
end
# Press the home button
def home_button
output, status = run_axe('button', 'home')
raise AxeError, "home button failed: #{output}" unless status.success?
end
# Press a key by keycode
# @param keycode [Integer] HID keycode (e.g., 40 for Return/Enter)
def press_key(keycode)
output, status = run_axe('key', keycode.to_s)
raise AxeError, "key press failed: #{output}" unless status.success?
end
# Press Return/Enter key
def press_return
press_key(40)
end
# Press Escape key
def press_escape
press_key(41)
end
# Take a screenshot and save it
# @param name [String] Screenshot name (without extension)
# @return [String] Path to the saved screenshot
def screenshot(name)
Config.ensure_directories!
path = File.join(Config.current_dir, "#{name}.png")
output, status = run_axe('screenshot', '--output', path)
raise AxeError, "screenshot failed: #{output}" unless status.success?
# Wait for file to be fully written to disk
wait_for_file(path)
path
end
private
def run_axe(*)
Open3.capture2e('axe', *, '--udid', @udid)
end
# Wait for a file to exist and have non-zero size
# Helps avoid race conditions where screenshot isn't fully written
# @param path [String] Path to the file
# @param timeout [Float] Maximum time to wait in seconds
def wait_for_file(path, timeout: 2.0)
start_time = Time.now
loop do
return if File.exist?(path) && File.size(path) > 100
break if Time.now - start_time > timeout
sleep 0.1
end
end
# Recursively search the accessibility tree for an element
# @param node [Hash, Array] Current node in the tree
# @param identifier [String, nil] Accessibility identifier to match (AXUniqueId)
# @param label [String, nil] Accessibility label to match (AXLabel)
# @return [Hash, nil] Found element or nil
def find_element_in_tree(node, identifier: nil, label: nil)
case node
when Hash
# Check if this node matches by identifier (AXUniqueId in AXe output)
return node if identifier && node['AXUniqueId'] == identifier
# Check if this node matches by label (AXLabel in AXe output)
return node if label && node['AXLabel']&.include?(label)
# Recursively search children
node.each_value do |value|
result = find_element_in_tree(value, identifier: identifier, label: label)
return result if result
end
when Array
node.each do |item|
result = find_element_in_tree(item, identifier: identifier, label: label)
return result if result
end
end
nil
end
end
end
# Add present? method for nil/empty checking
class Object
def present?
respond_to?(:empty?) ? !empty? : !nil?
end
end
class NilClass
def present?
false
end
end

View File

@@ -0,0 +1,91 @@
# frozen_string_literal: true
require 'rspec/expectations'
# Custom RSpec matchers for AXe-based UI testing
module UITest
module Matchers
# Matcher to check if an element with accessibility identifier exists
#
# Usage:
# expect(axe).to have_element("tab.library")
#
RSpec::Matchers.define :have_element do |identifier|
match do |axe|
axe.element_exists?(identifier)
end
failure_message do |_axe|
"expected to find element with accessibility identifier '#{identifier}' but it was not found"
end
failure_message_when_negated do |_axe|
"expected not to find element with accessibility identifier '#{identifier}' but it was found"
end
description do
"have element with accessibility identifier '#{identifier}'"
end
end
# Matcher to check if text is visible in the accessibility tree
#
# Usage:
# expect(axe).to have_text("Library")
#
RSpec::Matchers.define :have_text do |text|
match do |axe|
axe.text_visible?(text)
end
failure_message do |_axe|
"expected to find text '#{text}' but it was not visible"
end
failure_message_when_negated do |_axe|
"expected not to find text '#{text}' but it was visible"
end
description do
"have visible text '#{text}'"
end
end
# Matcher to compare screenshot to baseline
#
# Usage:
# expect(screenshot_path).to match_baseline
# expect(screenshot_path).to match_baseline(threshold: 0.02)
#
RSpec::Matchers.define :match_baseline do |threshold: UITest::Config.default_diff_threshold|
match do |screenshot_path|
@comparison = UITest::ScreenshotComparison.new(screenshot_path)
# If it's a known false positive, consider it a match
return true if @comparison.false_positive?
@comparison.matches_baseline?(threshold: threshold)
end
failure_message do |screenshot_path|
@comparison ||= UITest::ScreenshotComparison.new(screenshot_path)
msg = "screenshot '#{@comparison.name}' differs from baseline"
msg += " by #{(@comparison.diff_percentage * 100).round(2)}%"
msg += " (threshold: #{(threshold * 100).round(2)}%)"
msg += "\n Baseline: #{@comparison.baseline_path}"
msg += "\n Current: #{screenshot_path}"
msg += "\n Diff: #{@comparison.diff_path}" if File.exist?(@comparison.diff_path)
msg
end
failure_message_when_negated do |_screenshot_path|
'expected screenshot not to match baseline but it did'
end
description do
"match baseline screenshot within #{(threshold * 100).round(2)}% threshold"
end
end
end
end

228
spec/ui/support/config.rb Normal file
View File

@@ -0,0 +1,228 @@
# frozen_string_literal: true
module UITest
# Configuration for UI tests
class Config
class << self
# Device name for simulator (from env or default)
def device
ENV.fetch('UI_TEST_DEVICE', 'iPhone 17 Pro')
end
# Sanitized device name for file paths (replaces spaces and special chars)
def device_slug
device.gsub(/[^a-zA-Z0-9]/, '_')
end
# iOS version of the selected simulator
def ios_version
@ios_version ||= detect_ios_version
end
# Sanitized iOS version for file paths (e.g., "18_1")
def ios_version_slug
ios_version.gsub('.', '_')
end
# Combined device and iOS version slug for snapshot directories
def snapshot_slug
"#{device_slug}/iOS_#{ios_version_slug}"
end
private
# Detect iOS version from the simulator runtime
def detect_ios_version
require 'json'
output = `xcrun simctl list devices available -j 2>/dev/null`
data = JSON.parse(output)
# Find the device and extract iOS version from runtime key
data['devices'].each do |runtime, devices|
next unless runtime.include?('iOS')
devices.each do |dev|
next unless dev['name'] == device
# Runtime format: "com.apple.CoreSimulator.SimRuntime.iOS-18-1"
match = runtime.match(/iOS[.-](\d+)[.-](\d+)/)
return "#{match[1]}.#{match[2]}" if match
end
end
'unknown'
rescue StandardError
'unknown'
end
public
# App bundle identifier
def bundle_id
'stream.yattee.app'
end
# Yattee Server URL for testing (configurable via env)
def yattee_server_url
ENV.fetch('YATTEE_SERVER_URL', 'https://yp.home.arekf.net')
end
# Extract host from Yattee Server URL for identifier matching
def yattee_server_host
URI.parse(yattee_server_url).host
end
# Invidious URL for testing (configurable via env)
def invidious_url
ENV.fetch('INVIDIOUS_URL', 'https://invidious.home.arekf.net')
end
# Extract host from Invidious URL for identifier matching
def invidious_host
URI.parse(invidious_url).host
end
# Invidious account email for testing (configurable via env)
def invidious_email
ENV.fetch('INVIDIOUS_EMAIL', nil)
end
# Invidious account password for testing (configurable via env)
def invidious_password
ENV.fetch('INVIDIOUS_PASSWORD', nil)
end
# Whether Invidious credentials are configured
def invidious_credentials?
invidious_email && invidious_password
end
# Piped URL for testing (configurable via env)
def piped_url
ENV.fetch('PIPED_URL', 'https://pipedapi.home.arekf.net')
end
# Extract host from Piped URL for identifier matching
def piped_host
URI.parse(piped_url).host
end
# Piped account username for testing (configurable via env)
def piped_username
ENV.fetch('PIPED_USERNAME', nil)
end
# Piped account password for testing (configurable via env)
def piped_password
ENV.fetch('PIPED_PASSWORD', nil)
end
# Whether Piped credentials are configured
def piped_credentials?
piped_username && piped_password
end
# Xcode project path (parent of spec directory)
def project_path
File.expand_path('..', spec_root)
end
# Xcode project file
def xcodeproj
File.join(project_path, 'Yattee.xcodeproj')
end
# Scheme to build
def scheme
'Yattee'
end
# Build configuration
def configuration
'Debug'
end
# Derived data path for builds
def derived_data_path
File.join(project_path, 'build')
end
# Path to built app
def app_path
File.join(derived_data_path, 'Build', 'Products', 'Debug-iphonesimulator', 'Yattee.app')
end
# Spec root directory
def spec_root
File.expand_path('../..', __dir__)
end
# Snapshots directory
def snapshots_root
File.join(spec_root, 'ui_snapshots')
end
# Baseline screenshots directory (device and iOS version specific)
def baseline_dir
File.join(snapshots_root, 'baseline', snapshot_slug)
end
# Current test run screenshots directory (device and iOS version specific)
def current_dir
File.join(snapshots_root, 'current', snapshot_slug)
end
# Diff images directory (device and iOS version specific)
def diff_dir
File.join(snapshots_root, 'diff', snapshot_slug)
end
# False positives YAML file
def false_positives_file
File.join(snapshots_root, 'false_positives.yml')
end
# Default threshold for visual comparison (1% difference allowed)
def default_diff_threshold
0.01
end
# Whether to generate baseline screenshots
def generate_baseline?
ENV['GENERATE_BASELINE'] == '1'
end
# Whether to skip building the app
def skip_build?
ENV['SKIP_BUILD'] == '1'
end
# Whether to keep simulator running after tests
def keep_simulator?
ENV['KEEP_SIMULATOR'] == '1'
end
# Whether to keep app data between runs (skip uninstall)
def keep_app_data?
ENV['KEEP_APP_DATA'] == '1'
end
# Timeout for waiting for elements (seconds)
def element_timeout
10
end
# Time to wait for app to stabilize after launch (seconds)
def app_launch_wait
3
end
# Ensure directories exist
def ensure_directories!
[baseline_dir, current_dir, diff_dir].each do |dir|
FileUtils.mkdir_p(dir)
end
end
end
end
end

View File

@@ -0,0 +1,912 @@
# 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 Library 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.element_exists?("sources.row.yatteeServer.#{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 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)
sleep 0.8
# Wait for AddSourceView to appear
wait_for_element('addSource.urlField')
# Enter URL in text field
@axe.tap_id('addSource.urlField')
sleep 0.5
@axe.type(url)
sleep 0.5
# Tap Detect & Add button
@axe.tap_id('addSource.actionButton')
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')
# Close Settings (return to Library)
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.element_exists?("sources.row.invidious.#{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
# 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_first_element_with_id("sources.row.invidious.#{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_first_element_with_id("sources.row.invidious.#{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 Library
# 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 Library, attempt recovery if not
unless @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
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.element_exists?("sources.row.piped.#{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_first_element_with_id("sources.row.piped.#{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_first_element_with_id("sources.row.piped.#{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 Library
# 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 Library, attempt recovery if not
unless @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
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 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 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)
sleep 0.8
# Wait for AddSourceView to appear
wait_for_element('addSource.urlField')
# Enter URL in text field
@axe.tap_id('addSource.urlField')
sleep 0.5
@axe.type(url)
sleep 0.5
# Tap Detect & Add button
@axe.tap_id('addSource.actionButton')
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')
# Close Settings (return to Library)
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
# @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)
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 Library to Settings > Sources
def navigate_to_sources
# Ensure we're on Library tab
ensure_on_library
# Tap Settings button using accessibility identifier
@axe.tap_id('library.settingsButton')
sleep 1
# Wait for Settings view
wait_for_element('settings.view')
# Tap Sources row
@axe.tap_id('settings.row.sources')
sleep 0.5
# Wait for Sources list view
wait_for_element('sources.view')
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')
# Try to dismiss any sheets/modals
dismiss_any_sheets
# Final check - use a longer timeout
wait_for_library
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?('Library') || @axe.element_exists?('library.card.playlists')
rescue UITest::Axe::AxeError
# Not found
end
# Try Done by label
begin
@axe.tap_label('Done')
sleep 0.5
return if @axe.text_visible?('Library') || @axe.element_exists?('library.card.playlists')
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?('Library') || @axe.element_exists?('library.card.playlists')
# Last resort: home button
@axe.home_button
sleep 1
end
# Wait for Library view to appear
def wait_for_library(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')
raise "Library 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?('Library') || @axe.element_exists?('library.card.playlists')
# 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_label('Done')
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?('addSource.detectedType')
puts " Detection succeeded after #{(Time.now - start_time).round(1)}s"
return :success
end
# Check for error
if @axe.element_exists?('addSource.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
sleep 0.5
end
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

View File

@@ -0,0 +1,126 @@
# frozen_string_literal: true
module UITest
# Helper for player interactions in UI tests.
# Provides methods to wait for player states and control playback.
class PlayerHelper
def initialize(axe)
@axe = axe
end
# Wait for player sheet to be expanded
# @param timeout [Integer] Maximum time to wait in seconds
def wait_for_player_expanded(timeout: 15)
puts ' Waiting for player to expand...'
start_time = Time.now
loop do
# Check for expanded player using accessibility label
return true if @axe.text_visible?('player.expandedSheet')
elapsed = Time.now - start_time
if elapsed > timeout
@axe.screenshot('debug_player_expand_timeout')
raise "Player did not expand after #{timeout} seconds"
end
sleep 0.5
end
end
# Wait for playback to start (player expanded means video is loading/playing)
# @param timeout [Integer] Maximum time to wait in seconds
def wait_for_playback_started(timeout: 20)
puts ' Waiting for playback to start...'
start_time = Time.now
loop do
# Player expanded sheet being visible indicates playback started
# The specific controls may not be exposed in accessibility tree
if @axe.text_visible?('player.expandedSheet')
# Wait a bit more for video to actually start loading
sleep 2.0
puts " Playback started after #{(Time.now - start_time).round(1)}s"
return true
end
elapsed = Time.now - start_time
if elapsed > timeout
@axe.screenshot('debug_playback_start_timeout')
raise "Playback did not start after #{timeout} seconds"
end
sleep 0.5
end
end
# Tap Play button on video info sheet
def tap_play_button
puts ' Tapping Play button on video info...'
# Play button is in the action bar below video thumbnail
# Based on screenshot analysis: approximately (80, 400)
@axe.tap_coordinates(x: 80, y: 400)
sleep 0.5
end
# Close the player using the close button (X in bottom control bar)
def close_player
puts ' Closing player...'
# Close button is the X on the right side of the bottom control bar
# The control bar is a floating pill at the bottom
# Based on earlier AXe tree, buttons are around y=806-808
# The X button is the rightmost, at approximately x=350-360
@axe.tap_coordinates(x: 355, y: 810)
sleep 1
puts ' Player closed'
end
# Check if player is expanded
# @return [Boolean] true if player sheet is expanded
def player_expanded?
@axe.text_visible?('player.expandedSheet')
end
# Check if player is closed (not expanded)
# @return [Boolean] true if player sheet is not visible
def player_closed?
!@axe.text_visible?('player.expandedSheet')
end
# Check if mini player is visible
# @return [Boolean] true if mini player is visible
def mini_player_visible?
@axe.text_visible?('player.miniPlayer')
end
# Wait for player to be fully closed
# @param timeout [Integer] Maximum time to wait in seconds
def wait_for_player_closed(timeout: 10)
puts ' Waiting for player to close...'
start_time = Time.now
loop do
return true if player_closed?
elapsed = Time.now - start_time
if elapsed > timeout
@axe.screenshot('debug_player_close_timeout')
raise "Player did not close after #{timeout} seconds"
end
sleep 0.3
end
end
private
# Tap in the center of the screen to show player controls
# Controls auto-hide after a few seconds, so we need to tap to reveal them
def tap_to_show_controls
# Tap in the center of the video area to show controls
@axe.tap_coordinates(x: 200, y: 300)
sleep 0.5
end
end
end

View File

@@ -0,0 +1,206 @@
# frozen_string_literal: true
require 'open3'
require 'yaml'
require 'fileutils'
module UITest
# Handles visual regression testing by comparing screenshots
class ScreenshotComparison
class DependencyError < StandardError; end
attr_reader :current_path, :name
def initialize(current_path)
@current_path = current_path
@name = File.basename(current_path, '.png')
end
# Check if ImageMagick is installed
# @return [Boolean] true if ImageMagick compare command is available
def self.imagemagick_available?
@imagemagick_available ||= begin
_output, status = Open3.capture2e('which', 'compare')
status.success?
end
end
# Raise an error if ImageMagick is not installed
def self.require_imagemagick!
return if imagemagick_available?
raise DependencyError, <<~ERROR
ImageMagick is required for visual regression testing but was not found.
Install it with Homebrew:
brew install imagemagick
Or skip visual tests with:
./bin/ui-test --tag ~visual
ERROR
end
# Path to baseline screenshot
def baseline_path
File.join(Config.baseline_dir, "#{@name}.png")
end
# Path to diff image
def diff_path
File.join(Config.diff_dir, "#{@name}_diff.png")
end
# Check if baseline exists and is valid
def baseline_exists?
valid_png?(baseline_path)
end
# Check if current screenshot exists and is valid
def current_exists?
valid_png?(@current_path)
end
# Validate that a file exists and is a valid PNG
# @param path [String] Path to the file
# @return [Boolean] true if file exists and has valid PNG header
def valid_png?(path)
return false unless File.exist?(path)
return false unless File.size(path) > 8 # PNG header is 8 bytes minimum
# Check PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
File.open(path, 'rb') do |f|
header = f.read(8)
header&.bytes == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
end
rescue StandardError
false
end
# Compare current screenshot to baseline
# @param threshold [Float] Maximum allowed difference (0.0 to 1.0)
# @return [Boolean] true if screenshots match within threshold
def matches_baseline?(threshold: Config.default_diff_threshold)
# If generating baseline, always "matches" (we'll save it)
if Config.generate_baseline?
update_baseline
return true
end
# Validate current screenshot exists and is valid
unless current_exists?
puts " Current screenshot invalid or missing: #{@current_path}"
puts " File exists: #{File.exist?(@current_path)}, size: #{File.exist?(@current_path) ? File.size(@current_path) : 'N/A'}"
return false
end
# If no baseline exists, fail (unless generating)
unless baseline_exists?
puts " No baseline found: #{baseline_path}"
puts ' Run with --generate-baseline to create it'
return false
end
# Ensure ImageMagick is available for comparison
self.class.require_imagemagick!
# Compare using ImageMagick
diff = calculate_diff
matches = diff <= threshold
# Generate diff image if there's a mismatch
generate_diff_image unless matches
matches
end
# Calculate the difference percentage between current and baseline
# @return [Float] Difference as a percentage (0.0 to 1.0)
def diff_percentage
@diff_percentage ||= calculate_diff
end
# Generate a visual diff image highlighting differences
def generate_diff_image
Config.ensure_directories!
# Use ImageMagick compare to create a diff image
# AE = Absolute Error count, fuzz allows small color variations
_output, _status = Open3.capture2e(
'compare', '-metric', 'AE', '-fuzz', '5%',
'-highlight-color', 'red', '-lowlight-color', 'white',
baseline_path, @current_path, diff_path
)
# compare returns exit code 1 if images differ, which is expected
puts " Diff image saved: #{diff_path}" if File.exist?(diff_path)
end
# Copy current screenshot to baseline
def update_baseline
Config.ensure_directories!
FileUtils.cp(@current_path, baseline_path)
puts " Baseline updated: #{baseline_path}"
end
# Check if this screenshot is marked as a false positive
# @return [Boolean] true if marked as false positive
def false_positive?
fps = load_false_positives
fps.key?(@name)
end
# Get the reason for false positive
# @return [String, nil] Reason or nil if not a false positive
def false_positive_reason
fps = load_false_positives
fps.dig(@name, 'reason')
end
private
def calculate_diff
return 0.0 unless baseline_exists?
# Validate current screenshot before comparison
unless current_exists?
puts " Warning: Current screenshot invalid or empty: #{@current_path}"
return 1.0
end
# Use ImageMagick compare with RMSE (Root Mean Square Error)
# This gives us a normalized difference value
output, status = Open3.capture2e(
'compare', '-metric', 'RMSE',
baseline_path, @current_path, 'null:'
)
# Output format: "12345 (0.123456)" or "12345 (9.95509e-05)" for scientific notation
# We want the normalized value in parentheses
match = output.match(/\(([\d.e+-]+)\)/i)
unless match
# ImageMagick compare returns exit code 1 for different images (normal)
# but returns exit code 2 for errors - log these
if status.exitstatus == 2 || output.include?('error')
puts " Warning: ImageMagick comparison failed: #{output.strip}"
else
puts " Warning: Could not parse ImageMagick output: #{output.strip}"
end
return 1.0
end
match[1].to_f
rescue StandardError => e
puts " Warning: Failed to compare screenshots: #{e.message}"
1.0
end
def load_false_positives
return {} unless File.exist?(Config.false_positives_file)
YAML.load_file(Config.false_positives_file) || {}
rescue StandardError
{}
end
end
end

View File

@@ -0,0 +1,259 @@
# frozen_string_literal: true
module UITest
# Helper for search functionality in UI tests.
# Provides methods to navigate to search, enter queries, and interact with results.
class SearchHelper
# Coordinates for iPhone 17 Pro (402pt width based on AXe frame data)
# On iOS 26+ with TabView role: .search, the search field is integrated into the tab bar
# Search field frame from AXe: {{88, 798}, {286, 48}} - center is approximately (231, 822)
SEARCH_FIELD_COORDS = { x: 231, y: 822 }.freeze
# Search tab is on the right side of the tab bar (bottom of screen)
# Tab bar is at approximately y=815, search tab is rightmost
SEARCH_TAB_COORDS = { x: 350, y: 815 }.freeze
def initialize(axe)
@axe = axe
end
# Navigate to Search tab
def navigate_to_search
# Try accessibility ID first, fall back to coordinates
begin
@axe.tap_id('tab.search')
rescue UITest::Axe::AxeError
# Tab accessibility IDs may not work on iOS 26+ tab bars
# Use coordinates to tap the search tab (rightmost tab)
puts ' Using coordinates to tap Search tab'
@axe.tap_coordinates(**SEARCH_TAB_COORDS)
end
sleep 1.0
# Debug: Take screenshot and dump elements to see what's visible
puts ' [DEBUG] After tapping search tab, checking visible elements...'
puts " [DEBUG] text 'search.empty' visible? #{@axe.text_visible?('search.empty')}"
puts " [DEBUG] text 'search.recents' visible? #{@axe.text_visible?('search.recents')}"
puts " [DEBUG] text 'Search' visible? #{@axe.text_visible?('Search')}"
# Take debug screenshot
screenshot_path = @axe.screenshot('debug_after_search_tap')
puts " [DEBUG] Screenshot saved to: #{screenshot_path}"
# Dump accessibility tree for debugging
puts ' [DEBUG] Dumping accessibility tree labels...'
tree = @axe.describe_ui
dump_labels(tree)
puts ' [DEBUG] End of accessibility tree'
# The search tab with role: .search on iOS 26+ integrates searchable into tab bar
# The SearchView content should be visible - try waiting for the search field or view
wait_for_search_ready
end
# Wait for search to be ready (search view, empty state, or recents visible)
def wait_for_search_ready(timeout: 10)
start_time = Time.now
loop do
# Check for various search view states using accessibility labels
# (accessibilityIdentifier doesn't work on Group/some SwiftUI views)
return true if @axe.text_visible?('search.empty')
return true if @axe.text_visible?('search.recents')
# Also check for the navigation title "Search" as fallback
return true if @axe.text_visible?('Search')
elapsed = Time.now - start_time
raise "Search not ready after #{timeout} seconds" if elapsed > timeout
sleep 0.3
end
end
# Check if search view is displayed (any state)
def search_visible?
@axe.text_visible?('search.empty') ||
@axe.text_visible?('search.recents') ||
@axe.text_visible?('Search')
end
# Perform a search query
# @param query [String] The search query to enter
def search(query)
puts " Searching for: #{query}"
# First, check if this query exists in recent searches - if so, tap it directly
if @axe.text_visible?(query)
puts " [DEBUG] Found '#{query}' in recent searches, tapping it"
@axe.tap_label(query)
sleep 1.0
@axe.screenshot('debug_after_recent_tap')
return
end
# Take screenshot before tapping search field
@axe.screenshot('debug_before_search_tap')
# Try to tap on search field by label first, fall back to coordinates
begin
@axe.tap_label('Videos, channels, playlists')
puts ' [DEBUG] Tapped search field by label'
rescue UITest::Axe::AxeError
puts ' [DEBUG] Label tap failed, using coordinates'
@axe.tap_coordinates(**SEARCH_FIELD_COORDS)
end
sleep 0.5
# Take screenshot after tapping search field
@axe.screenshot('debug_after_search_field_tap')
# Type the query
@axe.type(query)
sleep 0.3
# Take screenshot after typing
@axe.screenshot('debug_after_typing')
# Submit the search by pressing Return key
# Using hardware key press instead of typing \n which doesn't work in iOS 26+ searchable
@axe.press_return
sleep 1.0
# Take screenshot after submitting
screenshot_path = @axe.screenshot('debug_after_search_submit')
puts " [DEBUG] After search submit: #{screenshot_path}"
end
# Wait for search results to appear
# @param timeout [Integer] Maximum time to wait in seconds
def wait_for_results(timeout: 30)
puts ' Waiting for search results...'
start_time = Time.now
loop do
# Check using accessibility labels (more reliable than identifiers)
return true if @axe.text_visible?('search.results')
# Check for no results or error states
return true if @axe.text_visible?('search.noResults')
elapsed = Time.now - start_time
if elapsed > timeout
# Take debug screenshot before failing
@axe.screenshot('debug_wait_for_results_timeout')
raise "Search results not found after #{timeout} seconds"
end
sleep 0.5
end
end
# Tap on a specific video by ID
# @param video_id [String] The video ID to tap
def tap_video(video_id)
puts " Tapping video: #{video_id}"
@axe.tap_id("video.row.#{video_id}")
sleep 0.5
end
# Tap the first video result by coordinates (text area - opens info)
# Used when individual video rows aren't accessible via accessibility tree
def tap_first_result
puts ' Tapping first search result by coordinates'
# First result is below filter strip (y≈122) with padding
# Video row center is approximately y=230 for first result
@axe.tap_coordinates(x: 200, y: 230)
sleep 0.5
end
# Tap the first video thumbnail to start playback directly
# Thumbnail is on the left side of the video row
def tap_first_result_thumbnail
puts ' Tapping first search result thumbnail to play'
# First result thumbnail is at approximately:
# x: 100 (center of thumbnail on left side)
# y: 180 (first result row)
@axe.tap_coordinates(x: 100, y: 180)
sleep 0.5
end
# Check if a video exists in results
# @param video_id [String] The video ID to check
# @return [Boolean] true if the video exists in results
def video_exists?(video_id)
@axe.text_visible?("video.row.#{video_id}")
end
# Wait for a specific video to appear in results
# @param video_id [String] The video ID to wait for
# @param timeout [Integer] Timeout in seconds
def wait_for_video(video_id, timeout: 30)
puts " Waiting for video: #{video_id}"
start_time = Time.now
loop do
return true if video_exists?(video_id)
elapsed = Time.now - start_time
if elapsed > timeout
@axe.screenshot('debug_wait_for_video_timeout')
raise "Video '#{video_id}' not found after #{timeout} seconds"
end
sleep 0.5
end
end
# Check if search results are displayed
# @return [Boolean] true if results are visible
def results_visible?
@axe.text_visible?('search.results')
end
# Check if no results message is displayed
# @return [Boolean] true if no results message is visible
def no_results_visible?
@axe.element_exists?('search.noResults')
end
# Check if search is loading
# @return [Boolean] true if loading indicator is visible
def loading?
@axe.element_exists?('search.loading')
end
private
# 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
# Debug helper to dump accessibility labels from tree
def dump_labels(node, depth = 0, max_depth = 10)
return if depth > max_depth
case node
when Hash
id = node['AXUniqueId']
label = node['AXLabel']
role = node['AXRole']
indent = ' ' * depth
puts "#{indent}[Role: #{role}] [ID: #{id}] [Label: #{label}]" if id || label || role
node.each_value { |v| dump_labels(v, depth + 1, max_depth) }
when Array
node.each { |item| dump_labels(item, depth, max_depth) }
end
end
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
# Shared context that ensures an Invidious instance is configured
# before running tests that depend on it.
#
# Usage in specs:
# RSpec.describe 'Feature requiring Invidious', :smoke do
# include_context 'with Invidious instance'
#
# it 'does something with Invidious' do
# # Instance is guaranteed to exist
# end
# end
#
RSpec.shared_context 'with Invidious instance' do
before(:all) do
@instance_setup ||= UITest::InstanceSetup.new(@axe)
@instance_setup.ensure_invidious(UITest::Config.invidious_url)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
# Shared context that ensures an Invidious instance is configured AND logged in
# before running tests that depend on it.
#
# Requires environment variables:
# INVIDIOUS_EMAIL - Invidious account email/username
# INVIDIOUS_PASSWORD - Invidious account password
#
# Usage in specs:
# RSpec.describe 'Feature requiring logged-in Invidious', :smoke do
# include_context 'with logged-in Invidious'
#
# it 'does something with Invidious account' do
# # Instance is guaranteed to exist and be logged in
# end
# end
#
RSpec.shared_context 'with logged-in Invidious' do
before(:all) do
skip 'Invidious credentials not configured' unless UITest::Config.invidious_credentials?
@instance_setup ||= UITest::InstanceSetup.new(@axe)
@instance_setup.ensure_invidious(UITest::Config.invidious_url)
@instance_setup.ensure_invidious_logged_in(UITest::Config.invidious_host)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
# Shared context that ensures a Piped instance is configured AND logged in
# before running tests that depend on it.
#
# Requires environment variables:
# PIPED_USERNAME - Piped account username
# PIPED_PASSWORD - Piped account password
#
# Usage in specs:
# RSpec.describe 'Feature requiring logged-in Piped', :smoke do
# include_context 'with logged-in Piped'
#
# it 'does something with Piped account' do
# # Instance is guaranteed to exist and be logged in
# end
# end
#
RSpec.shared_context 'with logged-in Piped' do
before(:all) do
skip 'Piped credentials not configured' unless UITest::Config.piped_credentials?
@instance_setup ||= UITest::InstanceSetup.new(@axe)
@instance_setup.ensure_piped(UITest::Config.piped_url)
@instance_setup.ensure_piped_logged_in(UITest::Config.piped_host)
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
# Shared context that ensures a Piped instance is configured
# before running tests that depend on it.
#
# Usage in specs:
# RSpec.describe 'Feature requiring Piped', :smoke do
# include_context 'with Piped instance'
#
# it 'does something with Piped' do
# # Instance is guaranteed to exist
# end
# end
#
RSpec.shared_context 'with Piped instance' do
before(:all) do
@instance_setup ||= UITest::InstanceSetup.new(@axe)
@instance_setup.ensure_piped(UITest::Config.piped_url)
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
# Shared context that ensures a Yattee Server instance is configured
# before running tests that depend on it.
#
# Usage in specs:
# RSpec.describe 'Feature requiring Yattee Server', :smoke do
# include_context 'with Yattee Server instance'
#
# it 'does something with Yattee Server' do
# # Instance is guaranteed to exist
# end
# end
#
RSpec.shared_context 'with Yattee Server instance' do
before(:all) do
@instance_setup ||= UITest::InstanceSetup.new(@axe)
@instance_setup.ensure_yattee_server(UITest::Config.yattee_server_url)
end
end

View File

@@ -0,0 +1,132 @@
# frozen_string_literal: true
require 'open3'
require 'json'
module UITest
# Manages iOS Simulator lifecycle
class Simulator
class SimulatorError < StandardError; end
class << self
# Boot a simulator by device name and return its UDID
# @param device_name [String] Name of the device (e.g., "iPhone 17 Pro")
# @return [String] UDID of the booted simulator
def boot(device_name)
udid = find_udid(device_name)
raise SimulatorError, "Simulator '#{device_name}' not found" unless udid
status = device_status(udid)
if status == 'Shutdown'
puts "Booting simulator '#{device_name}'..."
run_simctl('boot', udid)
wait_until_booted(udid)
elsif status == 'Booted'
puts "Simulator '#{device_name}' is already booted"
else
puts "Simulator '#{device_name}' is in state '#{status}', waiting..."
wait_until_booted(udid)
end
# Set consistent status bar for reproducible screenshots
set_status_bar_overrides(udid)
udid
end
# Shutdown a simulator by UDID
# @param udid [String] UDID of the simulator
def shutdown(udid)
return unless udid
status = device_status(udid)
return if status == 'Shutdown'
clear_status_bar_overrides(udid)
puts 'Shutting down simulator...'
run_simctl('shutdown', udid)
end
# Set status bar overrides for consistent screenshots
# Uses Apple's iconic 9:41 time and full signal/battery
# @param udid [String] UDID of the simulator
def set_status_bar_overrides(udid)
puts 'Setting status bar overrides for consistent screenshots...'
run_simctl(
'status_bar', udid, 'override',
'--time', '9:41',
'--batteryState', 'charged',
'--batteryLevel', '100',
'--wifiBars', '3',
'--cellularBars', '4'
)
end
# Clear status bar overrides
# @param udid [String] UDID of the simulator
def clear_status_bar_overrides(udid)
run_simctl('status_bar', udid, 'clear')
rescue SimulatorError
# Ignore errors when clearing (simulator may already be shut down)
nil
end
# Find UDID for a device by name
# @param device_name [String] Name of the device
# @return [String, nil] UDID or nil if not found
def find_udid(device_name)
output, status = Open3.capture2('xcrun', 'simctl', 'list', 'devices', 'available', '-j')
raise SimulatorError, 'Failed to list simulators' unless status.success?
data = JSON.parse(output)
devices = data['devices'].values.flatten
# Find exact match first
device = devices.find { |d| d['name'] == device_name }
device&.fetch('udid', nil)
end
# Get device status by UDID
# @param udid [String] UDID of the simulator
# @return [String] Status (e.g., "Booted", "Shutdown")
def device_status(udid)
output, status = Open3.capture2('xcrun', 'simctl', 'list', 'devices', '-j')
raise SimulatorError, 'Failed to get device status' unless status.success?
data = JSON.parse(output)
devices = data['devices'].values.flatten
device = devices.find { |d| d['udid'] == udid }
device&.fetch('state', 'Unknown') || 'Unknown'
end
# Wait until simulator is fully booted
# @param udid [String] UDID of the simulator
# @param timeout [Integer] Timeout in seconds
def wait_until_booted(udid, timeout: 60)
start_time = Time.now
loop do
status = device_status(udid)
return if status == 'Booted'
if Time.now - start_time > timeout
raise SimulatorError, "Timeout waiting for simulator to boot (status: #{status})"
end
sleep 1
end
end
private
def run_simctl(*args)
output, status = Open3.capture2e('xcrun', 'simctl', *args)
raise SimulatorError, "simctl #{args.first} failed: #{output}" unless status.success?
output
end
end
end
end