From 56cd60a8ba340126ae6c0c40c557ace32298a38d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 6 Apr 2026 22:12:10 +0200 Subject: [PATCH] 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. --- spec/ui/smoke/invidious_basic_auth_spec.rb | 162 +++++++++++++++++++++ spec/ui/support/config.rb | 40 +++++ spec/ui/support/instance_setup.rb | 117 +++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 spec/ui/smoke/invidious_basic_auth_spec.rb diff --git a/spec/ui/smoke/invidious_basic_auth_spec.rb b/spec/ui/smoke/invidious_basic_auth_spec.rb new file mode 100644 index 00000000..83ec3eef --- /dev/null +++ b/spec/ui/smoke/invidious_basic_auth_spec.rb @@ -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 diff --git a/spec/ui/support/config.rb b/spec/ui/support/config.rb index d1d4ec84..0977861a 100644 --- a/spec/ui/support/config.rb +++ b/spec/ui/support/config.rb @@ -112,6 +112,46 @@ module UITest invidious_email && invidious_password 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) def piped_url ENV.fetch('PIPED_URL', 'https://pipedapi.home.arekf.net') diff --git a/spec/ui/support/instance_setup.rb b/spec/ui/support/instance_setup.rb index 2864fbc6..f1cc6c7f 100644 --- a/spec/ui/support/instance_setup.rb +++ b/spec/ui/support/instance_setup.rb @@ -186,6 +186,73 @@ module UITest remove_instance("sources.row.invidious.#{host}", host) 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 # @param host [String] Host portion of the server URL # @return [Boolean] true if logged in @@ -1035,6 +1102,56 @@ module UITest fields 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] 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 def print_element_tree(node, depth = 0) return unless node.is_a?(Hash)