mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
224
spec/ui/smoke/piped_login_spec.rb
Normal file
224
spec/ui/smoke/piped_login_spec.rb
Normal 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
|
||||||
@@ -54,6 +54,60 @@ module UITest
|
|||||||
false
|
false
|
||||||
end
|
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
|
# Tap on an element by accessibility identifier
|
||||||
# @param identifier [String] Accessibility identifier
|
# @param identifier [String] Accessibility identifier
|
||||||
def tap_id(identifier)
|
def tap_id(identifier)
|
||||||
|
|||||||
Reference in New Issue
Block a user