mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
Add UI smoke tests for Invidious behind HTTP Basic Auth
Three end-to-end specs that exercise the new basic-auth flows against a
real Invidious instance fronted by an nginx reverse proxy:
1. add flow: types the URL, hits Detect, fills the basic-auth fields
when the basicAuthRequired UI state appears, taps Retry Detection,
and confirms the instance lands in the Sources list.
2. state assertion: types a URL, taps Detect, and verifies the form
transitions into the basicAuthRequired state (Retry Detection button
present, no detected type) when no credentials were supplied.
3. proxied login: ensures the instance exists, then drives the standard
Invidious login flow with the proxied account credentials. Confirms
the SID Cookie auth coexists with the per-client Authorization
header on the basic-auth-aware HTTPClient.
Test infrastructure additions:
- spec/ui/support/config.rb: env-driven accessors for the basic-auth URL
and proxied-account credentials. No secrets committed.
- spec/ui/support/instance_setup.rb: helpers
add_invidious_with_basic_auth, remove_and_add_invidious_with_basic_auth,
find_basic_auth_text_fields (mirroring find_auth_text_fields), and
fill_field for tapping a discovered field by frame and typing into it.
All three specs skip cleanly when the relevant env vars are not set.
This commit is contained in:
162
spec/ui/smoke/invidious_basic_auth_spec.rb
Normal file
162
spec/ui/smoke/invidious_basic_auth_spec.rb
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative '../spec_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Invidious behind HTTP Basic Auth', :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
|
||||||
|
UITest::App.terminate(udid: @udid, silent: true) if @udid
|
||||||
|
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Each example must start from a clean Home view. If a previous example
|
||||||
|
# left the app on the AddRemoteServer form (or any other sheet), dismiss it.
|
||||||
|
before(:each) do
|
||||||
|
@instance_setup.send(:dismiss_any_sheets)
|
||||||
|
@instance_setup.send(:wait_for_home, timeout: 15)
|
||||||
|
rescue StandardError => e
|
||||||
|
warn " before(:each) reset failed: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# On failure, take a screenshot so we can see what state the simulator was in.
|
||||||
|
after(:each) do |example|
|
||||||
|
next unless example.exception
|
||||||
|
|
||||||
|
safe_name = example.full_description.gsub(/\W+/, '_')[0, 80]
|
||||||
|
begin
|
||||||
|
path = @axe.screenshot("invidious-basic-auth-FAIL-#{safe_name}")
|
||||||
|
warn " Failure screenshot saved to: #{path}"
|
||||||
|
rescue StandardError => e
|
||||||
|
warn " Failed to capture screenshot: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'add flow with basic-auth-required detection' do
|
||||||
|
it 'retries detection with credentials and adds the instance' do
|
||||||
|
skip 'INVIDIOUS_BASIC_AUTH_USERNAME / _PASSWORD not set' unless UITest::Config.invidious_basic_auth_credentials?
|
||||||
|
|
||||||
|
url = UITest::Config.invidious_basic_auth_url
|
||||||
|
host = UITest::Config.invidious_basic_auth_host
|
||||||
|
|
||||||
|
expect(@axe).to have_text('Home')
|
||||||
|
|
||||||
|
@instance_setup.remove_and_add_invidious_with_basic_auth(
|
||||||
|
url,
|
||||||
|
username: UITest::Config.invidious_basic_auth_username,
|
||||||
|
password: UITest::Config.invidious_basic_auth_password
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the instance is in Sources
|
||||||
|
@axe.tap_id('home.settingsButton')
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
expect(@axe).to have_element('settings.view')
|
||||||
|
|
||||||
|
@axe.tap_id('settings.row.sources')
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
expect(@axe).to have_text(host)
|
||||||
|
|
||||||
|
begin
|
||||||
|
@axe.tap_id('settings.doneButton')
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
sleep 0.5
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'detection without credentials surfaces basic-auth-required state' do
|
||||||
|
it 'shows the Retry Detection button when the proxy returns 401' do
|
||||||
|
url = UITest::Config.invidious_basic_auth_url
|
||||||
|
|
||||||
|
@instance_setup.send(:navigate_to_sources)
|
||||||
|
@instance_setup.send(:tap_add_source_button)
|
||||||
|
sleep 0.8
|
||||||
|
@instance_setup.send(:select_remote_server_tab)
|
||||||
|
|
||||||
|
@instance_setup.send(:wait_for_element, 'addRemoteServer.urlField')
|
||||||
|
@axe.tap_id('addRemoteServer.urlField')
|
||||||
|
sleep 0.5
|
||||||
|
@axe.type(url)
|
||||||
|
sleep 0.5
|
||||||
|
@axe.tap_id('addRemoteServer.detectButton')
|
||||||
|
|
||||||
|
# Should land in the basicAuthRequired UI state, not the success path.
|
||||||
|
@instance_setup.send(:wait_for_element, 'addRemoteServer.retryDetectionButton', timeout: 20)
|
||||||
|
expect(@axe).to have_element('addRemoteServer.retryDetectionButton')
|
||||||
|
expect(@axe).not_to have_element('addRemoteServer.detectedType')
|
||||||
|
|
||||||
|
# Cancel and return Home so subsequent specs start clean.
|
||||||
|
begin
|
||||||
|
@axe.tap_label('Cancel')
|
||||||
|
rescue StandardError
|
||||||
|
@axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
|
||||||
|
end
|
||||||
|
sleep 0.5
|
||||||
|
@instance_setup.send(:close_settings)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'logging in to the proxied Invidious account' do
|
||||||
|
it 'logs in via SID over the basic-auth channel' do
|
||||||
|
skip 'Invidious basic-auth + proxied creds not set' unless
|
||||||
|
UITest::Config.invidious_basic_auth_credentials? &&
|
||||||
|
UITest::Config.invidious_proxied_credentials?
|
||||||
|
|
||||||
|
url = UITest::Config.invidious_basic_auth_url
|
||||||
|
host = UITest::Config.invidious_basic_auth_host
|
||||||
|
|
||||||
|
# Ensure the instance exists (idempotent — adds it if a previous spec hasn't already)
|
||||||
|
unless @instance_setup.invidious_exists?(host)
|
||||||
|
@instance_setup.add_invidious_with_basic_auth(
|
||||||
|
url,
|
||||||
|
username: UITest::Config.invidious_basic_auth_username,
|
||||||
|
password: UITest::Config.invidious_basic_auth_password
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# login_invidious reads Config.invidious_email/_password (which themselves
|
||||||
|
# read INVIDIOUS_EMAIL / INVIDIOUS_PASSWORD), so swap those env vars for the
|
||||||
|
# duration of the call to use the proxied account credentials.
|
||||||
|
with_env(
|
||||||
|
'INVIDIOUS_EMAIL' => UITest::Config.invidious_proxied_email,
|
||||||
|
'INVIDIOUS_PASSWORD' => UITest::Config.invidious_proxied_password
|
||||||
|
) do
|
||||||
|
@instance_setup.login_invidious(host)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(@instance_setup.invidious_logged_in?(host)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Temporarily replace ENV vars for the duration of a block.
|
||||||
|
def with_env(overrides)
|
||||||
|
previous = overrides.to_h { |k, _| [k, ENV[k]] }
|
||||||
|
overrides.each { |k, v| ENV[k] = v }
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -112,6 +112,46 @@ module UITest
|
|||||||
invidious_email && invidious_password
|
invidious_email && invidious_password
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Invidious URL for an instance fronted by HTTP Basic Auth (configurable via env)
|
||||||
|
def invidious_basic_auth_url
|
||||||
|
ENV.fetch('INVIDIOUS_BASIC_AUTH_URL', 'https://i03.s.yattee.stream')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract host from the basic-auth Invidious URL
|
||||||
|
def invidious_basic_auth_host
|
||||||
|
URI.parse(invidious_basic_auth_url).host
|
||||||
|
end
|
||||||
|
|
||||||
|
# HTTP Basic Auth username for the proxy in front of the Invidious instance
|
||||||
|
def invidious_basic_auth_username
|
||||||
|
ENV.fetch('INVIDIOUS_BASIC_AUTH_USERNAME', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# HTTP Basic Auth password for the proxy in front of the Invidious instance
|
||||||
|
def invidious_basic_auth_password
|
||||||
|
ENV.fetch('INVIDIOUS_BASIC_AUTH_PASSWORD', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether the basic-auth proxy credentials for Invidious are configured
|
||||||
|
def invidious_basic_auth_credentials?
|
||||||
|
invidious_basic_auth_username && invidious_basic_auth_password
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invidious account email/username behind the basic-auth proxy
|
||||||
|
def invidious_proxied_email
|
||||||
|
ENV.fetch('INVIDIOUS_PROXIED_EMAIL', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invidious account password behind the basic-auth proxy
|
||||||
|
def invidious_proxied_password
|
||||||
|
ENV.fetch('INVIDIOUS_PROXIED_PASSWORD', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether the proxied Invidious account credentials are configured
|
||||||
|
def invidious_proxied_credentials?
|
||||||
|
invidious_proxied_email && invidious_proxied_password
|
||||||
|
end
|
||||||
|
|
||||||
# Piped URL for testing (configurable via env)
|
# Piped URL for testing (configurable via env)
|
||||||
def piped_url
|
def piped_url
|
||||||
ENV.fetch('PIPED_URL', 'https://pipedapi.home.arekf.net')
|
ENV.fetch('PIPED_URL', 'https://pipedapi.home.arekf.net')
|
||||||
|
|||||||
@@ -186,6 +186,73 @@ module UITest
|
|||||||
remove_instance("sources.row.invidious.#{host}", host)
|
remove_instance("sources.row.invidious.#{host}", host)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Add an Invidious instance whose detection is fronted by HTTP Basic Auth.
|
||||||
|
# Drives the new RemoteServerUIState.basicAuthRequired path: type the URL,
|
||||||
|
# tap Detect, fill the basic-auth fields when the Retry Detection button
|
||||||
|
# appears, retry, then tap Add Source.
|
||||||
|
# @param url [String] Full URL of the Invidious instance
|
||||||
|
# @param username [String] HTTP Basic Auth username
|
||||||
|
# @param password [String] HTTP Basic Auth password
|
||||||
|
def add_invidious_with_basic_auth(url, username:, password:)
|
||||||
|
navigate_to_sources
|
||||||
|
|
||||||
|
tap_add_source_button
|
||||||
|
sleep 0.8
|
||||||
|
|
||||||
|
select_remote_server_tab
|
||||||
|
|
||||||
|
wait_for_element('addRemoteServer.urlField')
|
||||||
|
|
||||||
|
@axe.tap_id('addRemoteServer.urlField')
|
||||||
|
sleep 0.5
|
||||||
|
@axe.type(url)
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
@axe.tap_id('addRemoteServer.detectButton')
|
||||||
|
|
||||||
|
# First detection should fail with .basicAuthRequired — the Retry Detection
|
||||||
|
# button appears together with the username/password fields.
|
||||||
|
wait_for_element('addRemoteServer.retryDetectionButton', timeout: 20)
|
||||||
|
|
||||||
|
sleep 0.3
|
||||||
|
|
||||||
|
fields = find_basic_auth_text_fields
|
||||||
|
raise 'Could not find basic-auth username/password fields' if fields.length < 2
|
||||||
|
|
||||||
|
fill_field(fields[0], username)
|
||||||
|
fill_field(fields[1], password)
|
||||||
|
|
||||||
|
@axe.tap_id('addRemoteServer.retryDetectionButton')
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# After a successful retry, detection should reveal the standard form.
|
||||||
|
result = wait_for_detection(timeout: 20)
|
||||||
|
raise "Detection retry failed: #{result}" if result == :error
|
||||||
|
|
||||||
|
wait_for_element('addRemoteServer.actionButton')
|
||||||
|
@axe.tap_id('addRemoteServer.actionButton')
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
wait_for_add_complete(timeout: 20)
|
||||||
|
close_settings
|
||||||
|
end
|
||||||
|
|
||||||
|
# Remove the basic-auth Invidious instance if it exists, then add it fresh.
|
||||||
|
# @param url [String] Full URL of the Invidious instance
|
||||||
|
# @param username [String] HTTP Basic Auth username
|
||||||
|
# @param password [String] HTTP Basic Auth password
|
||||||
|
def remove_and_add_invidious_with_basic_auth(url, username:, password:)
|
||||||
|
host = URI.parse(url).host
|
||||||
|
|
||||||
|
if invidious_exists?(host)
|
||||||
|
puts " Removing existing Invidious instance: #{host}"
|
||||||
|
remove_invidious(host)
|
||||||
|
end
|
||||||
|
|
||||||
|
puts " Adding Invidious instance behind basic auth: #{url}"
|
||||||
|
add_invidious_with_basic_auth(url, username: username, password: password)
|
||||||
|
end
|
||||||
|
|
||||||
# Check if logged in to Invidious instance
|
# Check if logged in to Invidious instance
|
||||||
# @param host [String] Host portion of the server URL
|
# @param host [String] Host portion of the server URL
|
||||||
# @return [Boolean] true if logged in
|
# @return [Boolean] true if logged in
|
||||||
@@ -1035,6 +1102,56 @@ module UITest
|
|||||||
fields
|
fields
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Find the username/password text fields under the "HTTP Basic Authentication"
|
||||||
|
# section header in AddRemoteServerView. Mirrors find_auth_text_fields but
|
||||||
|
# anchored on the localized header used for non-Yattee-Server instance types.
|
||||||
|
# Falls back to a plain "Authentication" header for Yattee Server forms.
|
||||||
|
# @return [Array<Hash>] Array of text field elements
|
||||||
|
def find_basic_auth_text_fields
|
||||||
|
tree = @axe.describe_ui
|
||||||
|
fields = []
|
||||||
|
found_header = false
|
||||||
|
|
||||||
|
collect = lambda do |node|
|
||||||
|
return unless node.is_a?(Hash)
|
||||||
|
|
||||||
|
if node['role'] == 'AXHeading' &&
|
||||||
|
(node['AXLabel']&.include?('HTTP Basic Authentication') ||
|
||||||
|
node['AXLabel']&.include?('Authentication'))
|
||||||
|
found_header = true
|
||||||
|
end
|
||||||
|
|
||||||
|
if found_header && (node['role'] == 'AXTextField' || node['role'] == 'AXSecureTextField')
|
||||||
|
fields << node
|
||||||
|
end
|
||||||
|
|
||||||
|
return if node['AXUniqueId'] == 'addRemoteServer.actionButton' ||
|
||||||
|
node['AXUniqueId'] == 'addRemoteServer.retryDetectionButton'
|
||||||
|
|
||||||
|
(node['children'] || []).each { |child| collect.call(child) }
|
||||||
|
end
|
||||||
|
|
||||||
|
if tree.is_a?(Array)
|
||||||
|
tree.each { |root| collect.call(root) }
|
||||||
|
end
|
||||||
|
|
||||||
|
fields
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tap a text field discovered via the accessibility tree and type into it.
|
||||||
|
# @param field [Hash] Element hash from describe_ui (must have a 'frame')
|
||||||
|
# @param text [String] Text to type after focusing the field
|
||||||
|
def fill_field(field, text)
|
||||||
|
frame = field['frame']
|
||||||
|
@axe.tap_coordinates(
|
||||||
|
x: frame['x'] + (frame['width'] / 2),
|
||||||
|
y: frame['y'] + (frame['height'] / 2)
|
||||||
|
)
|
||||||
|
sleep 0.3
|
||||||
|
@axe.type(text)
|
||||||
|
sleep 0.3
|
||||||
|
end
|
||||||
|
|
||||||
# Debug helper to print UI element tree
|
# Debug helper to print UI element tree
|
||||||
def print_element_tree(node, depth = 0)
|
def print_element_tree(node, depth = 0)
|
||||||
return unless node.is_a?(Hash)
|
return unless node.is_a?(Hash)
|
||||||
|
|||||||
Reference in New Issue
Block a user