Add UI smoke test for Piped authenticated endpoints

Adds a Piped instance, logs in, and exercises the two settings flows that
hit the regressed endpoints directly — Import Subscriptions (/subscriptions)
and Import Playlists (/user/playlists). Asserts that "session is a required
parameter" never appears in the AX tree, catching the recent header-vs-query
auth regression end to end.

Promotes three tree-walking helpers (id_in_tree?, id_with_prefix_in_tree?,
label_in_tree?) onto UITest::Axe so the spec can fetch the AX tree once per
poll iteration and run all checks against it locally — roughly 6× fewer
`axe` subprocess spawns than calling element_exists? / text_visible? per
check, and a primitive other specs can reuse.
This commit is contained in:
Arkadiusz Fal
2026-05-06 20:17:28 +02:00
parent 6f8aa9a1b3
commit e3f4d764cc
2 changed files with 278 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
# frozen_string_literal: true
require_relative '../spec_helper'
# Regression test for the "session is a required parameter" Piped auth bug
# (introduced in build 259 by commit aed78c13f, which moved the auth token from
# the `Authorization` header to a `?authToken=` query parameter on endpoints
# that the Piped backend only accepts via the header).
#
# Reproduces by adding a Piped instance, logging in, then exercising the two
# settings flows that hit the broken endpoints directly:
# - Import Subscriptions → /subscriptions
# - Import Playlists → /user/playlists
#
# When the bug is present, the Piped backend returns
# `{"error":"session is a required parameter"}` and the import view surfaces
# either an explicit error element or that exact text in the AX tree. This spec
# fails (red) on a buggy build and passes (green) once the API client sends the
# token via the Authorization header.
RSpec.describe 'Piped Login Endpoints', :smoke do
before(:all) do
skip 'Piped credentials not configured' unless UITest::Config.piped_credentials?
@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)
@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
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
describe 'authenticated endpoints do not return "session is a required parameter"' do
it 'loads Import Subscriptions without the session error' do
open_import_view('sources.import.subscriptions')
result, last_tree = wait_for_import_settled(prefix: 'import.subscriptions')
safe_screenshot('piped-login-import-subscriptions')
expect(UITest::Axe.label_in_tree?(last_tree, 'session is a required parameter')).to(
be(false),
'Piped /subscriptions returned "session is a required parameter" — ' \
'auth token is being sent in the wrong place (query param instead of Authorization header).'
)
expect(%i[list empty]).to include(result),
"Expected /subscriptions to return list or empty state, got #{result.inspect}."
ensure
close_import_view
end
it 'loads Import Playlists without the session error' do
open_import_view('sources.import.playlists')
result, last_tree = wait_for_import_settled(prefix: 'import.playlists')
safe_screenshot('piped-login-import-playlists')
expect(UITest::Axe.label_in_tree?(last_tree, 'session is a required parameter')).to(
be(false),
'Piped /user/playlists returned "session is a required parameter" — ' \
'auth token is being sent in the wrong place (query param instead of Authorization header).'
)
expect(%i[list empty]).to include(result),
"Expected /user/playlists to return list or empty state, got #{result.inspect}."
ensure
close_import_view
end
end
private
# Navigate Settings → Sources → Piped row → tap given import navigation link.
def open_import_view(import_link_id)
return_to_home
@instance_setup.send(:navigate_to_sources)
@instance_setup.send(:tap_element_containing_text, UITest::Config.piped_host)
sleep 0.8
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
@axe.tap_id(import_link_id)
sleep 1
end
# Get back to the Home tab. Tries gentle recovery first; as a last resort,
# terminates and re-launches the app (login state survives in keychain/UserDefaults).
def return_to_home(timeout: 15)
# Give the app a moment to settle after the previous navigation.
sleep 1.0
return if on_home?
start = Time.now
loop do
return if on_home?
attempt_gentle_dismissals
return if on_home?
if Time.now - start > timeout
warn '[piped_login_spec] Could not reach Home gently — relaunching app'
safe_screenshot('debug-stuck-not-home')
UITest::App.terminate(udid: @udid, silent: true)
sleep 0.5
UITest::App.launch(udid: @udid)
sleep UITest::Config.app_launch_wait
return if on_home?
raise "Could not return to Home even after relaunch (#{timeout}s elapsed)"
end
sleep 0.5
end
end
def on_home?
@axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
rescue UITest::Axe::AxeError
false
end
def attempt_gentle_dismissals
# Try labels for sheets/nav back, but DO NOT swipe down from the top —
# an iOS swipe-down from the upper screen pulls down Spotlight and ejects us
# from the app.
%w[Cancel Done Back].each do |label|
begin
@axe.tap_label(label)
sleep 0.4
return if on_home?
rescue UITest::Axe::AxeError
next
end
end
begin
@axe.tap_id('settings.doneButton')
sleep 0.4
rescue UITest::Axe::AxeError
# ignore
end
end
# Wait for the import view to settle. Each iteration fetches the AX tree once
# and runs all checks against it locally — that's ~6× fewer `axe` subprocess
# spawns than calling element_exists? / text_visible? per check. Returns
# `[state, tree]` where state is :list / :empty / :error / :unknown. iOS 26
# doesn't reliably propagate the AXUniqueId from ContentUnavailableView, so
# empty/error states are also detected by their visible localized titles.
def wait_for_import_settled(prefix:, timeout: 25)
empty_titles = {
'import.subscriptions' => 'No Subscriptions',
'import.playlists' => 'No Playlists'
}
empty_title = empty_titles.fetch(prefix)
start_time = Time.now
last_tree = nil
loop do
tree = @axe.describe_ui rescue nil
last_tree = tree if tree
if tree
return [:list, tree] if UITest::Axe.id_with_prefix_in_tree?(tree, "#{prefix}.row.")
if UITest::Axe.id_in_tree?(tree, "#{prefix}.empty") || UITest::Axe.label_in_tree?(tree, empty_title)
return [:empty, tree]
end
if UITest::Axe.id_in_tree?(tree, "#{prefix}.error") ||
UITest::Axe.label_in_tree?(tree, 'session is a required parameter') ||
UITest::Axe.label_in_tree?(tree, 'Failed to load')
return [:error, tree]
end
end
break if Time.now - start_time > timeout
sleep 0.5
end
[:unknown, last_tree || {}]
end
def close_import_view
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
begin
@instance_setup.send(:close_edit_sheet)
@instance_setup.send(:close_settings)
rescue StandardError
nil
end
end
def safe_screenshot(name)
@axe.screenshot(name)
rescue UITest::Axe::AxeError => e
warn " screenshot '#{name}' failed: #{e.message}"
end
end

View File

@@ -54,6 +54,60 @@ module UITest
false
end
# Whether any element in the given tree has an AXUniqueId starting with the
# given prefix. Pass an already-fetched tree to avoid re-spawning `axe`.
def self.id_with_prefix_in_tree?(node, prefix)
case node
when Hash
return true if node['AXUniqueId']&.start_with?(prefix)
node.each_value do |value|
return true if id_with_prefix_in_tree?(value, prefix)
end
when Array
node.each do |item|
return true if id_with_prefix_in_tree?(item, prefix)
end
end
false
end
# Whether any element in the given tree has an AXLabel containing `text`.
# Pass an already-fetched tree to avoid re-spawning `axe`.
def self.label_in_tree?(node, text)
case node
when Hash
return true if node['AXLabel']&.include?(text)
node.each_value do |value|
return true if label_in_tree?(value, text)
end
when Array
node.each do |item|
return true if label_in_tree?(item, text)
end
end
false
end
# Whether any element in the given tree has the given AXUniqueId. Pass an
# already-fetched tree to avoid re-spawning `axe`.
def self.id_in_tree?(node, identifier)
case node
when Hash
return true if node['AXUniqueId'] == identifier
node.each_value do |value|
return true if id_in_tree?(value, identifier)
end
when Array
node.each do |item|
return true if id_in_tree?(item, identifier)
end
end
false
end
# Tap on an element by accessibility identifier
# @param identifier [String] Accessibility identifier
def tap_id(identifier)