Files
yattee/YatteeTests/Integration/InvidiousAPIIntegrationTests.swift
Arkadiusz Fal c778ca5d06 Fix flaky integration tests and UI test runner robustness
- Skip Invidious integration tests gracefully on .noConnection so a
  transient instance outage no longer fails CI
- Point integration tests at i01.v.yattee.stream (the previous test
  instance was decommissioned)
- Force UTF-8 on AXe CLI output in the UI test wrapper; ASCII-tagged
  bytes were crashing JSON.parse in describe_ui
- Add iOS 26.4 visual baselines for app-launch-home and settings-main
2026-05-10 15:28:11 +02:00

363 lines
13 KiB
Swift

//
// InvidiousAPIIntegrationTests.swift
// YatteeTests
//
// Integration tests for InvidiousAPI against a real instance.
// These tests make actual network requests and validate response parsing.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Integration Test Tag
extension Tag {
/// Tag for integration tests that require network access.
@Tag static var integration: Self
}
// MARK: - Invidious API Integration Tests
@Suite("Invidious API Integration Tests", .tags(.integration), .serialized)
struct InvidiousAPIIntegrationTests {
let api: InvidiousAPI
let instance: Instance
init() {
let httpClient = HTTPClient()
self.api = InvidiousAPI(httpClient: httpClient)
self.instance = IntegrationTestConstants.testInstance
}
/// Runs an integration network call, returning `nil` if the test instance is
/// unreachable so the test can skip its assertions without failing. Other
/// errors are rethrown.
private func tryInstance<T>(_ work: () async throws -> T) async throws -> T? {
do {
return try await work()
} catch APIError.noConnection {
return nil
}
}
// MARK: - Trending Tests
@Test("Trending returns videos or handles unavailable")
func trendingReturnsVideos() async throws {
do {
let videos = try await api.trending(instance: instance)
// If trending is available, it should return videos
if !videos.isEmpty {
#expect(videos.count >= 1, "Trending should return at least one video")
}
} catch {
// Trending may not be enabled on all instances - skip gracefully
// This is acceptable for integration tests
}
}
@Test("Trending videos have required fields when available")
func trendingVideosHaveRequiredFields() async throws {
do {
let videos = try await api.trending(instance: instance)
guard let video = videos.first else {
// No videos is acceptable
return
}
#expect(!video.id.videoID.isEmpty, "Video should have an ID")
#expect(!video.title.isEmpty, "Video should have a title")
#expect(!video.author.name.isEmpty, "Video should have an author name")
#expect(video.duration >= 0, "Video should have non-negative duration")
} catch {
// Trending may not be available
}
}
@Test("Trending videos have thumbnails when available")
func trendingVideosHaveThumbnails() async throws {
do {
let videos = try await api.trending(instance: instance)
guard let video = videos.first else {
return
}
#expect(!video.thumbnails.isEmpty, "Video should have thumbnails")
#expect(video.bestThumbnail != nil, "Video should have a best thumbnail")
} catch {
// Trending may not be available
}
}
// MARK: - Search Tests
@Test("Search returns results")
func searchReturnsResults() async throws {
guard let result = try await tryInstance({
try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 1
)
}) else { return }
#expect(!result.videos.isEmpty, "Search should return videos")
}
@Test("Search videos have required fields")
func searchVideosHaveRequiredFields() async throws {
guard let result = try await tryInstance({
try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 1
)
}) else { return }
guard let video = result.videos.first else {
Issue.record("No videos returned from search")
return
}
#expect(!video.id.videoID.isEmpty, "Search video should have an ID")
#expect(!video.title.isEmpty, "Search video should have a title")
}
@Test("Search pagination works")
func searchPaginationWorks() async throws {
guard let page1 = try await tryInstance({
try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 1
)
}) else { return }
#expect(page1.nextPage != nil, "First page should have a next page")
guard let page2 = try await tryInstance({
try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 2
)
}) else { return }
// Page 2 should have different videos (if enough results exist)
if !page1.videos.isEmpty && !page2.videos.isEmpty {
let page1IDs = Set(page1.videos.map { $0.id.videoID })
let page2IDs = Set(page2.videos.map { $0.id.videoID })
let overlap = page1IDs.intersection(page2IDs)
// Allow some overlap but not complete overlap
#expect(overlap.count < page1.videos.count, "Page 2 should have different videos than page 1")
}
}
@Test("Search suggestions returns strings")
func searchSuggestionsReturnsStrings() async throws {
guard let suggestions = try await tryInstance({
try await api.searchSuggestions(
query: "never gonna",
instance: instance
)
}) else { return }
#expect(!suggestions.isEmpty, "Search suggestions should return results")
#expect(suggestions.contains { $0.lowercased().contains("never") }, "Suggestions should be relevant to query")
}
// MARK: - Video Details Tests
@Test("Video details returns complete info")
func videoDetailsReturnsCompleteInfo() async throws {
guard let video = try await tryInstance({
try await api.video(id: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
#expect(video.id.videoID == IntegrationTestConstants.testVideoID, "Should return correct video")
#expect(!video.title.isEmpty, "Video should have a title")
#expect(!video.author.name.isEmpty, "Video should have an author")
#expect(video.duration > 0, "Video should have positive duration")
#expect(video.viewCount ?? 0 > 0, "Popular video should have views")
}
@Test("Video details includes thumbnails")
func videoDetailsIncludesThumbnails() async throws {
guard let video = try await tryInstance({
try await api.video(id: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
#expect(!video.thumbnails.isEmpty, "Video should have thumbnails")
let thumbnail = video.thumbnails.first!
#expect(thumbnail.url.absoluteString.contains("http"), "Thumbnail should have valid URL")
}
@Test("Video details includes author info")
func videoDetailsIncludesAuthorInfo() async throws {
guard let video = try await tryInstance({
try await api.video(id: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
#expect(!video.author.id.isEmpty, "Author should have an ID")
#expect(!video.author.name.isEmpty, "Author should have a name")
}
// MARK: - Streams Tests
@Test("Streams includes HLS when available")
func streamsIncludesHLS() async throws {
guard let streams = try await tryInstance({
try await api.streams(videoID: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
// HLS may not be available on all instances
let hlsStream = streams.first { $0.format == "hls" }
if let hls = hlsStream {
#expect(hls.mimeType == "application/x-mpegURL", "HLS should have correct MIME type")
}
// Test passes whether HLS is available or not
}
@Test("Streams includes multiple formats")
func streamsIncludesMultipleFormats() async throws {
guard let streams = try await tryInstance({
try await api.streams(videoID: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
#expect(streams.count > 1, "Should have multiple streams")
let formats = Set(streams.map { $0.format })
#expect(formats.count > 1, "Should have multiple formats")
}
@Test("Streams includes video resolutions")
func streamsIncludesVideoResolutions() async throws {
guard let streams = try await tryInstance({
try await api.streams(videoID: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
let videoStreams = streams.filter { $0.resolution != nil && !$0.isAudioOnly }
#expect(!videoStreams.isEmpty, "Should have video streams with resolutions")
let hasHD = videoStreams.contains { ($0.resolution?.height ?? 0) >= 720 }
#expect(hasHD, "Popular video should have HD streams")
}
@Test("Streams includes audio-only tracks")
func streamsIncludesAudioOnlyTracks() async throws {
guard let streams = try await tryInstance({
try await api.streams(videoID: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
let audioStreams = streams.filter { $0.isAudioOnly }
#expect(!audioStreams.isEmpty, "Should have audio-only streams")
}
// MARK: - Channel Tests
@Test("Channel returns info")
func channelReturnsInfo() async throws {
guard let channel = try await tryInstance({
try await api.channel(id: IntegrationTestConstants.testChannelID, instance: instance)
}) else { return }
#expect(channel.id.channelID == IntegrationTestConstants.testChannelID, "Should return correct channel")
#expect(!channel.name.isEmpty, "Channel should have a name")
#expect(channel.subscriberCount ?? 0 > 0, "Popular channel should have subscribers")
}
@Test("Channel includes thumbnail")
func channelIncludesThumbnail() async throws {
guard let channel = try await tryInstance({
try await api.channel(id: IntegrationTestConstants.testChannelID, instance: instance)
}) else { return }
#expect(channel.thumbnailURL != nil, "Channel should have thumbnail URL")
}
@Test("Channel videos returns videos")
func channelVideosReturnsVideos() async throws {
guard let page = try await tryInstance({
try await api.channelVideos(id: IntegrationTestConstants.testChannelID, instance: instance, continuation: nil)
}) else { return }
#expect(!page.videos.isEmpty, "Channel should have videos")
let video = page.videos.first!
#expect(!video.title.isEmpty, "Channel video should have title")
}
// MARK: - Comments Tests
@Test("Comments returns results or handles disabled")
func commentsReturnsResultsOrHandlesDisabled() async throws {
do {
let page = try await api.comments(
videoID: IntegrationTestConstants.testVideoID,
instance: instance,
continuation: nil
)
// If we get here, comments are enabled
#expect(!page.comments.isEmpty, "Video with comments should return some")
let comment = page.comments.first!
#expect(!comment.id.isEmpty, "Comment should have ID")
#expect(!comment.content.isEmpty, "Comment should have content")
#expect(!comment.author.name.isEmpty, "Comment should have author")
} catch APIError.commentsDisabled {
// Comments disabled is acceptable - test passes
} catch APIError.noConnection {
// Test instance unreachable - skip
} catch {
throw error
}
}
// MARK: - Captions Tests
@Test("Captions returns available tracks")
func captionsReturnsAvailableTracks() async throws {
guard let captions = try await tryInstance({
try await api.captions(videoID: IntegrationTestConstants.testVideoID, instance: instance)
}) else { return }
// Popular video should have captions, but it's not guaranteed
if !captions.isEmpty {
let caption = captions.first!
#expect(!caption.label.isEmpty, "Caption should have label")
#expect(!caption.languageCode.isEmpty, "Caption should have language code")
#expect(caption.url.absoluteString.contains("http"), "Caption should have valid URL")
}
}
// MARK: - Error Handling Tests
@Test("Invalid video ID returns error")
func invalidVideoIDReturnsError() async throws {
do {
_ = try await api.video(id: "invalid_video_id_that_does_not_exist", instance: instance)
Issue.record("Expected error for invalid video ID")
} catch {
// Any error is acceptable - notFound, requestFailed, etc.
// Different instances may return different error codes
}
}
@Test("Invalid channel ID returns error")
func invalidChannelIDReturnsError() async throws {
do {
_ = try await api.channel(id: "invalid_channel_id_xyz", instance: instance)
Issue.record("Expected error for invalid channel ID")
} catch {
// Any error is acceptable - notFound, requestFailed, etc.
// Different instances may return different error codes
}
}
}