mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
71
spec/ui/smoke/app_launch_spec.rb
Normal file
71
spec/ui/smoke/app_launch_spec.rb
Normal 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
|
||||
353
spec/ui/smoke/import_playlists_piped_spec.rb
Normal file
353
spec/ui/smoke/import_playlists_piped_spec.rb
Normal 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
|
||||
353
spec/ui/smoke/import_playlists_spec.rb
Normal file
353
spec/ui/smoke/import_playlists_spec.rb
Normal 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
|
||||
313
spec/ui/smoke/import_subscriptions_piped_spec.rb
Normal file
313
spec/ui/smoke/import_subscriptions_piped_spec.rb
Normal 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
|
||||
315
spec/ui/smoke/import_subscriptions_spec.rb
Normal file
315
spec/ui/smoke/import_subscriptions_spec.rb
Normal 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
|
||||
73
spec/ui/smoke/invidious_spec.rb
Normal file
73
spec/ui/smoke/invidious_spec.rb
Normal 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
|
||||
73
spec/ui/smoke/piped_spec.rb
Normal file
73
spec/ui/smoke/piped_spec.rb
Normal 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
|
||||
47
spec/ui/smoke/player_controls_preview_spec.rb
Normal file
47
spec/ui/smoke/player_controls_preview_spec.rb
Normal 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
|
||||
63
spec/ui/smoke/search_spec.rb
Normal file
63
spec/ui/smoke/search_spec.rb
Normal 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
|
||||
107
spec/ui/smoke/settings_spec.rb
Normal file
107
spec/ui/smoke/settings_spec.rb
Normal 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
|
||||
76
spec/ui/smoke/video_playback_invidious_spec.rb
Normal file
76
spec/ui/smoke/video_playback_invidious_spec.rb
Normal 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
|
||||
79
spec/ui/smoke/video_playback_piped_spec.rb
Normal file
79
spec/ui/smoke/video_playback_piped_spec.rb
Normal 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
|
||||
71
spec/ui/smoke/video_playback_spec.rb
Normal file
71
spec/ui/smoke/video_playback_spec.rb
Normal 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
|
||||
73
spec/ui/smoke/yattee_server_spec.rb
Normal file
73
spec/ui/smoke/yattee_server_spec.rb
Normal 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
74
spec/ui/spec_helper.rb
Normal 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
120
spec/ui/support/app.rb
Normal 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
210
spec/ui/support/axe.rb
Normal 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
|
||||
91
spec/ui/support/axe_matchers.rb
Normal file
91
spec/ui/support/axe_matchers.rb
Normal 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
228
spec/ui/support/config.rb
Normal 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
|
||||
912
spec/ui/support/instance_setup.rb
Normal file
912
spec/ui/support/instance_setup.rb
Normal 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
|
||||
126
spec/ui/support/player_helper.rb
Normal file
126
spec/ui/support/player_helper.rb
Normal 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
|
||||
206
spec/ui/support/screenshot_comparison.rb
Normal file
206
spec/ui/support/screenshot_comparison.rb
Normal 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
|
||||
259
spec/ui/support/search_helper.rb
Normal file
259
spec/ui/support/search_helper.rb
Normal 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
|
||||
20
spec/ui/support/shared_contexts/with_invidious.rb
Normal file
20
spec/ui/support/shared_contexts/with_invidious.rb
Normal 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
|
||||
27
spec/ui/support/shared_contexts/with_logged_in_invidious.rb
Normal file
27
spec/ui/support/shared_contexts/with_logged_in_invidious.rb
Normal 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
|
||||
27
spec/ui/support/shared_contexts/with_logged_in_piped.rb
Normal file
27
spec/ui/support/shared_contexts/with_logged_in_piped.rb
Normal 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
|
||||
20
spec/ui/support/shared_contexts/with_piped.rb
Normal file
20
spec/ui/support/shared_contexts/with_piped.rb
Normal 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
|
||||
20
spec/ui/support/shared_contexts/with_yattee_server.rb
Normal file
20
spec/ui/support/shared_contexts/with_yattee_server.rb
Normal 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
|
||||
132
spec/ui/support/simulator.rb
Normal file
132
spec/ui/support/simulator.rb
Normal 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
|
||||
Reference in New Issue
Block a user