diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fee815bcd..906c85099 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -40,6 +40,23 @@ jobs: set -eo pipefail xcodebuild test-without-building -scheme "$scheme" -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + navigator-ui-tests: + name: Navigator UI Tests + runs-on: macos-14 + if: ${{ !github.event.pull_request.draft }} + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install dependencies + run: | + brew update + brew install xcodegen + - name: Test + run: | + set -eo pipefail + make navigator-ui-tests-project + xcodebuild test -project Tests/NavigatorTests/UITests/NavigatorUITests.xcodeproj -scheme NavigatorTestHost -destination "platform=$platform,name=$device" | if command -v xcpretty &> /dev/null; then xcpretty; else cat; fi + lint: name: Lint runs-on: macos-14 diff --git a/Makefile b/Makefile index 3f66b6af4..4cdeb32af 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ carthage-project: rm -rf $(SCRIPTS_PATH)/node_modules/ xcodegen -s Support/Carthage/project.yml --use-cache --cache-path Support/Carthage/.xcodegen +.PHONY: navigator-ui-tests-project +navigator-ui-tests-project: + xcodegen -s Tests/NavigatorTests/UITests/project.yml + .PHONY: scripts scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) @@ -32,11 +36,6 @@ update-scripts: @which corepack >/dev/null 2>&1 || (echo "ERROR: corepack is required, please install it first\nhttps://pnpm.io/installation#using-corepack"; exit 1) pnpm install --dir "$(SCRIPTS_PATH)" -.PHONY: test -test: - # To limit to a particular test suite: -only-testing:ReadiumSharedTests - xcodebuild test -scheme "Readium-Package" -destination "platform=iOS Simulator,name=iPhone 15" | xcbeautify -q - .PHONY: lint-format lint-format: swift run --package-path BuildTools swiftformat --lint . diff --git a/Package.swift b/Package.swift index 5856660f0..8ef42cf5d 100644 --- a/Package.swift +++ b/Package.swift @@ -53,7 +53,10 @@ let package = Package( ), .testTarget( name: "ReadiumSharedTests", - dependencies: ["ReadiumShared"], + dependencies: [ + "ReadiumShared", + "TestPublications", + ], path: "Tests/SharedTests", resources: [ .copy("Fixtures"), @@ -101,7 +104,10 @@ let package = Package( .testTarget( name: "ReadiumNavigatorTests", dependencies: ["ReadiumNavigator"], - path: "Tests/NavigatorTests" + path: "Tests/NavigatorTests", + exclude: [ + "UITests", + ] ), .target( @@ -140,7 +146,7 @@ let package = Package( // dependencies: ["ReadiumLCP"], // path: "Tests/LCPTests", // resources: [ - // .copy("Fixtures"), + // .copy("../Fixtures"), // ] // ), @@ -171,5 +177,14 @@ let package = Package( dependencies: ["ReadiumInternal"], path: "Tests/InternalTests" ), + + // Shared test publications used across multiple test targets. + .target( + name: "TestPublications", + path: "Tests/Publications", + resources: [ + .copy("Publications"), + ] + ), ] ) diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 24eda8d15..55772bbad 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -33,6 +33,16 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { ) } + override func clear() { + super.clear() + + // Clean up go to continuations. + for continuation in goToContinuations { + continuation.resume() + } + goToContinuations.removeAll() + } + override func setupWebView() { super.setupWebView() @@ -193,7 +203,6 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { // Location to scroll to in the resource once the page is loaded. private var pendingLocation: PageLocation = .start - @MainActor override func go(to location: PageLocation) async { guard isSpreadLoaded else { // Delays moving to the location until the document is loaded. @@ -215,14 +224,12 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { didCompleteGoTo() } - @MainActor private func waitGoToCompletion() async { await withCheckedContinuation { continuation in goToContinuations.append(continuation) } } - @MainActor private func didCompleteGoTo() { for cont in goToContinuations { cont.resume() @@ -230,7 +237,6 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { goToContinuations.removeAll() } - @MainActor private var goToContinuations: [CheckedContinuation] = [] @discardableResult diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index 27a52c2b9..b540d1fb6 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -95,6 +95,13 @@ class EPUBSpreadView: UIView, Loggable, PageView { deinit { NotificationCenter.default.removeObserver(self) + clear() + } + + /// Called when the spread view is removed from the view hierarchy, to + /// clear pending operations and retain cycles. + func clear() { + // Disable JS messages to break WKUserContentController reference. disableJSMessages() } @@ -126,14 +133,18 @@ class EPUBSpreadView: UIView, Loggable, PageView { webView.scrollView } + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + clear() + } + } + override func didMoveToSuperview() { super.didMoveToSuperview() - if superview == nil { - disableJSMessages() - // Fixing an iOS 9 bug by explicitly clearing scrollView.delegate before deinitialization - scrollView.delegate = nil - } else { + if superview != nil { enableJSMessages() scrollView.delegate = self } diff --git a/Sources/Navigator/Toolkit/PaginationView.swift b/Sources/Navigator/Toolkit/PaginationView.swift index 5035417ce..b12d4dbb8 100644 --- a/Sources/Navigator/Toolkit/PaginationView.swift +++ b/Sources/Navigator/Toolkit/PaginationView.swift @@ -150,6 +150,18 @@ final class PaginationView: UIView, Loggable { scrollView.contentOffset.x = xOffsetForIndex(currentIndex) } + override func willMove(toSuperview newSuperview: UIView?) { + super.willMove(toSuperview: newSuperview) + + if newSuperview == nil { + // Remove all spread views to break retain cycles + for (_, view) in loadedViews { + view.removeFromSuperview() + } + loadedViews.removeAll() + } + } + override func didMoveToWindow() { super.didMoveToWindow() diff --git a/Tests/NavigatorTests/UITests/.gitignore b/Tests/NavigatorTests/UITests/.gitignore new file mode 100644 index 000000000..4640ebbac --- /dev/null +++ b/Tests/NavigatorTests/UITests/.gitignore @@ -0,0 +1 @@ +*.xcodeproj diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift new file mode 100644 index 000000000..f82f4fe46 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/AccessibilityID.swift @@ -0,0 +1,21 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import SwiftUI + +enum AccessibilityID: String { + case open + case close + case allMemoryDeallocated + case isNavigatorReady +} + +extension View { + func accessibilityIdentifier(_ id: AccessibilityID) -> ModifiedContent { + accessibilityIdentifier(id.rawValue) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift new file mode 100644 index 000000000..6b9d43931 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift @@ -0,0 +1,80 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumAdapterGCDWebServer +import ReadiumNavigator +import ReadiumShared +import ReadiumStreamer +import UIKit + +/// Shared Readium infrastructure for testing. +@MainActor class Container { + static let shared = Container() + + let memoryTracker = MemoryTracker() + let httpClient: HTTPClient + let httpServer: HTTPServer + let assetRetriever: AssetRetriever + let publicationOpener: PublicationOpener + + init() { + httpClient = DefaultHTTPClient() + assetRetriever = AssetRetriever(httpClient: httpClient) + httpServer = GCDHTTPServer(assetRetriever: assetRetriever) + + publicationOpener = PublicationOpener( + parser: DefaultPublicationParser( + httpClient: httpClient, + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory() + ), + contentProtections: [] + ) + } + + func publication(at url: FileURL) async throws -> Publication { + let asset = try await assetRetriever.retrieve(url: url).get() + let publication = try await publicationOpener.open( + asset: asset, + allowUserInteraction: false, + sender: nil + ).get() + + memoryTracker.track(publication) + return publication + } + + func navigator(for publication: Publication) throws -> VisualNavigator & UIViewController { + if publication.conforms(to: .epub) { + return try epubNavigator(for: publication) + } else if publication.conforms(to: .pdf) { + return try pdfNavigator(for: publication) + } else { + fatalError("Publication not supported") + } + } + + func epubNavigator(for publication: Publication) throws -> EPUBNavigatorViewController { + let navigator = try EPUBNavigatorViewController( + publication: publication, + initialLocation: nil, + config: EPUBNavigatorViewController.Configuration(), + httpServer: httpServer + ) + memoryTracker.track(navigator) + return navigator + } + + func pdfNavigator(for publication: Publication) throws -> PDFNavigatorViewController { + let navigator = try PDFNavigatorViewController( + publication: publication, + initialLocation: nil, + httpServer: httpServer + ) + memoryTracker.track(navigator) + return navigator + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift new file mode 100644 index 000000000..20d81f62d --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/FixtureList.swift @@ -0,0 +1,103 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumShared +import SwiftUI + +/// Provides a simple UI for opening publication fixtures and display memory +/// status for UI test verification. +struct FixtureList: View { + @ObservedObject private var memoryTracker: MemoryTracker + @StateObject private var viewModel = FixtureListViewModel() + + init() { + memoryTracker = Container.shared.memoryTracker + } + + var body: some View { + List { + Section { + fixture(.childrensLiteratureEPUB) + fixture(.daisyPDF) + } + + Section { + Toggle(isOn: $memoryTracker.allDeallocated) { + Text("All memory is deallocated") + } + .accessibilityIdentifier(.allMemoryDeallocated) + } + .disabled(true) + } + .fullScreenCover(item: $viewModel.readerViewModel) { viewModel in + ReaderView(viewModel: viewModel) + } + } + + private func fixture(_ fixture: PublicationFixture) -> some View { + ListRow(action: { viewModel.open(fixture) }) { + VStack(alignment: .leading) { + Text(fixture.filename) + .font(.headline) + + Text(fixture.description) + .font(.caption) + } + + Spacer() + + Image(systemName: "chevron.right") + } + .accessibilityIdentifier(fixture.accessibilityIdentifier) + } +} + +@MainActor +class FixtureListViewModel: ObservableObject { + @Published var readerViewModel: ReaderViewModel? + + private var openTask: Task? + + func open(_ fixture: PublicationFixture) { + openTask?.cancel() + openTask = Task { try! await open(fixture) } + } + + private func open(_ fixture: PublicationFixture) async throws { + let components = fixture.filename.split(separator: ".", maxSplits: 1) + .map { String($0) } + + guard + components.count == 2, + let epubURL = Bundle.main.url( + forResource: components[0], + withExtension: components[1], + subdirectory: "Publications" + ) + else { + throw FixtureError.notFound(fixture) + } + + let fileURL = FileURL(url: epubURL)! + + let container = Container.shared + let publication = try await container.publication(at: fileURL) + let navigator = try container.navigator(for: publication) + + readerViewModel = ReaderViewModel(navigator: navigator) + } +} + +enum FixtureError: LocalizedError { + case notFound(PublicationFixture) + + var errorDescription: String? { + switch self { + case let .notFound(fixture): + return "Test fixture \(fixture.filename) not found in bundle" + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift new file mode 100644 index 000000000..7ca33ee7d --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Fixtures/PublicationFixture.swift @@ -0,0 +1,26 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +struct PublicationFixture { + let filename: String + let description: String + + var accessibilityIdentifier: String { + "publication://\(filename)" + } + + static let childrensLiteratureEPUB: PublicationFixture = .init( + filename: "childrens-literature.epub", + description: "Basic reflowable EPUB with a page-list." + ) + + static let daisyPDF: PublicationFixture = .init( + filename: "daisy.pdf", + description: "Basic PDF document." + ) +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist b/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist new file mode 100644 index 000000000..ee46fcec7 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift new file mode 100644 index 000000000..a04fa0c53 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ListRow.swift @@ -0,0 +1,43 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +struct ListRow: View { + private let action: (@MainActor () async -> Void)? + private let content: () -> Content + + @State private var isActionRunning = false + + init( + action: (() async -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.action = action + self.content = content + } + + var body: some View { + HStack { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.interaction, .rect) + .onTapGesture(perform: activate) + .disabled(isActionRunning) + } + + private func activate() { + guard let action, !isActionRunning else { + return + } + isActionRunning = true + Task { @MainActor in + await action() + isActionRunning = false + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift new file mode 100644 index 000000000..f5bbdb802 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/MemoryTracker.swift @@ -0,0 +1,67 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +import ReadiumNavigator + +/// Tracks object instances to detect memory leaks in UI tests. +@MainActor class MemoryTracker: ObservableObject { + @Published var allDeallocated: Bool = true + + class Ref { + private weak var object: AnyObject? + + var isDeallocated: Bool { + object == nil + } + + init(_ object: AnyObject) { + self.object = object + } + } + + private var refs: [Ref] = [] + private var pollingTask: Task? + + /// Records a weak reference to track. + @discardableResult + func track(_ object: T) -> Ref { + let ref = Ref(object) + refs.append(ref) + startPollingIfNeeded() + return ref + } + + private func startPollingIfNeeded() { + guard pollingTask == nil else { return } + + pollingTask = Task { + while !Task.isCancelled { + try? await Task.sleep(seconds: 0.5) + pollAllocations() + } + } + } + + private func stopPolling() { + pollingTask?.cancel() + pollingTask = nil + } + + private func pollAllocations() { + refs.removeAll { $0.isDeallocated } + let deallocated = refs.isEmpty + + if allDeallocated != deallocated { + allDeallocated = deallocated + } + + // Stop polling when no objects are being tracked + if refs.isEmpty { + stopPolling() + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift new file mode 100644 index 000000000..75ad43108 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/NavigatorTestHostApp.swift @@ -0,0 +1,16 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import SwiftUI + +@main +struct NavigatorTestHostApp: App { + var body: some Scene { + WindowGroup { + FixtureList() + } + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift new file mode 100644 index 000000000..a180aa756 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderView.swift @@ -0,0 +1,72 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumNavigator +import ReadiumShared +import SwiftUI + +/// SwiftUI wrapper for the `ReaderViewController`. +struct ReaderView: View { + @ObservedObject var viewModel: ReaderViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + ReaderViewControllerWrapper(navigator: viewModel.navigator) + // State information checked in UI tests, not meant to be + // visible. + .background( + List { + Toggle(isOn: $viewModel.isReady) {} + .accessibilityIdentifier(.isNavigatorReady) + } + ) + .ignoresSafeArea(.all) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + .accessibilityIdentifier(.close) + } + } + } + } +} + +@MainActor final class ReaderViewModel: ObservableObject, Identifiable { + nonisolated var id: ObjectIdentifier { ObjectIdentifier(self) } + + let navigator: VisualNavigator & UIViewController + + @Published var isReady: Bool = false + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + + if let epubNavigator = navigator as? EPUBNavigatorViewController { + epubNavigator.delegate = self + } else if let pdfNavigator = navigator as? PDFNavigatorViewController { + pdfNavigator.delegate = self + } + } +} + +// MARK: - NavigatorDelegate + +extension ReaderViewModel: NavigatorDelegate { + func navigator(_ navigator: Navigator, presentError error: NavigatorError) {} + + func navigator(_ navigator: Navigator, locationDidChange locator: Locator) { + if !isReady { + isReady = true + } + } +} + +extension ReaderViewModel: EPUBNavigatorDelegate {} +extension ReaderViewModel: PDFNavigatorDelegate {} diff --git a/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift new file mode 100644 index 000000000..c65cc77b5 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorTestHost/ReaderViewController.swift @@ -0,0 +1,48 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import ReadiumNavigator +import SwiftUI +import UIKit + +class ReaderViewController: UIViewController { + private let navigator: VisualNavigator & UIViewController + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init?(coder: NSCoder) not implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Add navigator as child view controller + addChild(navigator) + navigator.view.frame = view.bounds + navigator.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(navigator.view) + navigator.didMove(toParent: self) + } +} + +struct ReaderViewControllerWrapper: UIViewControllerRepresentable { + let navigator: VisualNavigator & UIViewController + + init(navigator: VisualNavigator & UIViewController) { + self.navigator = navigator + } + + func makeUIViewController(context: Context) -> ReaderViewController { + ReaderViewController(navigator: navigator) + } + + func updateUIViewController(_ uiViewController: ReaderViewController, context: Context) {} +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist b/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist new file mode 100644 index 000000000..64d65ca49 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift new file mode 100644 index 000000000..88283538a --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/MemoryLeakTests.swift @@ -0,0 +1,47 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +/// These tests verify that navigator instances are properly deallocated when +/// dismissed. +/// +/// The host app maintains a weak reference to the navigator. If the navigator +/// is properly deallocated after dismissal, the weak reference becomes nil. +/// If it remains non-nil, a retain cycle or memory leak exists. +final class MemoryLeakTests: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + func testEPUBNavigatorDeallocatesAfterClosing() throws { + app + .open(.childrensLiteratureEPUB, waitUntilReady: true) + .close(assertMemoryDeallocated: true) + } + + func testEPUBNavigatorDeallocatesAfterClosingBeforeReady() throws { + app + .open(.childrensLiteratureEPUB, waitUntilReady: false) + .close(assertMemoryDeallocated: true) + } + + func testPDFNavigatorDeallocatesAfterClosing() throws { + app + .open(.daisyPDF, waitUntilReady: true) + .close(assertMemoryDeallocated: true) + } + + func testPDFNavigatorDeallocatesAfterClosingBeforeReady() throws { + app + .open(.daisyPDF, waitUntilReady: false) + .close(assertMemoryDeallocated: true) + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift new file mode 100644 index 000000000..6165e8429 --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIApplication.swift @@ -0,0 +1,65 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +extension XCUIApplication { + /// Opens a publication fixture. + @discardableResult + func open(_ fixture: PublicationFixture, waitUntilReady: Bool = true) -> ReaderUI { + staticTexts[fixture.accessibilityIdentifier].firstMatch.tap() + + let reader = ReaderUI(app: self) + + if waitUntilReady { + // Give the navigator time to fully load content. + reader.assertReady() + } + + return reader + } + + /// Checks that some memory is allocated in the app. + @discardableResult + func assertSomeMemoryAllocated() -> Self { + switches[.allMemoryDeallocated].assertIs(false) + return self + } + + /// Checks that all the tracked memory is deallocated in the app. + /// + /// A timeout is used to make sure the memory is cleared. + @discardableResult + func assertAllMemoryDeallocated() -> Self { + switches[.allMemoryDeallocated].assertIs(true, waitForTimeout: 30) + return self + } +} + +struct ReaderUI { + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + /// Activates the Close button. + @discardableResult + func close(assertMemoryDeallocated: Bool = true) -> XCUIApplication { + app.buttons[.close].tap() + if assertMemoryDeallocated { + app.assertAllMemoryDeallocated() + } + return app + } + + /// Waits for the navigator to be ready. + @discardableResult + func assertReady(timeout: TimeInterval = 30) -> Self { + app.switches[.isNavigatorReady].assertIs(true, waitForTimeout: timeout) + return self + } +} diff --git a/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift new file mode 100644 index 000000000..95bd6f9ea --- /dev/null +++ b/Tests/NavigatorTests/UITests/NavigatorUITests/XCUIElement.swift @@ -0,0 +1,43 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import XCTest + +extension XCUIElementQuery { + subscript(id: AccessibilityID) -> XCUIElement { + self[id.rawValue] + } +} + +extension XCUIElement { + var stringValue: String? { + value as? String + } + + func assertIsOn() { + assertIs(true) + } + + func assertIsOff() { + assertIs(false) + } + + func assertIs(_ on: Bool, waitForTimeout timeout: TimeInterval? = nil) { + let expectedValue = on ? "1" : "0" + let message = "Expected to be \(on ? "on" : "off")" + + if let timeout = timeout { + XCTAssertTrue(wait(toBe: on, timeout: timeout), message) + } else { + XCTAssertEqual(stringValue, expectedValue, message) + } + } + + func wait(toBe on: Bool, timeout: TimeInterval) -> Bool { + let expectedValue = on ? "1" : "0" + return wait(for: \.stringValue, toEqual: expectedValue, timeout: timeout) + } +} diff --git a/Tests/NavigatorTests/UITests/README.md b/Tests/NavigatorTests/UITests/README.md new file mode 100644 index 000000000..e89749c26 --- /dev/null +++ b/Tests/NavigatorTests/UITests/README.md @@ -0,0 +1,20 @@ +# Navigator UI Tests + +This test host app provides a controlled environment for running UI tests against Readium Navigators in a real app context with full WebKit and SwiftUI lifecycle. It's designed to be simple and maintainable, avoiding the complexity of the main TestApp. + +## Generate Xcode Project + +```bash +cd Tests/NavigatorTests/UITests +xcodegen generate +``` + +This creates `NavigatorUITests.xcodeproj` from `project.yml`. + +## Running Tests from Xcode + +1. Open `NavigatorUITests.xcodeproj` +2. Select the `NavigatorTestHost` scheme +3. Choose a simulator (iPhone or iPad) +4. Run tests: Cmd+U or Product > Test + diff --git a/Tests/NavigatorTests/UITests/project.yml b/Tests/NavigatorTests/UITests/project.yml new file mode 100644 index 000000000..19891c759 --- /dev/null +++ b/Tests/NavigatorTests/UITests/project.yml @@ -0,0 +1,51 @@ +name: NavigatorUITests +options: + bundleIdPrefix: org.readium.test +packages: + Readium: + path: ../../.. +schemes: + NavigatorTestHost: + build: + targets: + NavigatorTestHost: all + test: + targets: + - NavigatorUITests +targets: + NavigatorTestHost: + type: application + platform: iOS + deploymentTarget: 15.0 + sources: + - path: NavigatorTestHost + - path: ../../Publications/Publications + type: folder + buildPhase: resources + dependencies: + - package: Readium + product: ReadiumShared + - package: Readium + product: ReadiumStreamer + - package: Readium + product: ReadiumNavigator + - package: Readium + product: ReadiumAdapterGCDWebServer + settings: + INFOPLIST_FILE: NavigatorTestHost/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: org.readium.test.NavigatorTestHost + + NavigatorUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: 15.0 + sources: + - NavigatorUITests + - NavigatorTestHost/AccessibilityID.swift + - NavigatorTestHost/Fixtures + dependencies: + - target: NavigatorTestHost + settings: + INFOPLIST_FILE: NavigatorUITests/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: org.readium.test.NavigatorUITests + TEST_TARGET_NAME: NavigatorTestHost diff --git a/Tests/SharedTests/Fixtures/Fetcher/epub.epub b/Tests/Publications/Publications/childrens-literature.epub similarity index 100% rename from Tests/SharedTests/Fixtures/Fetcher/epub.epub rename to Tests/Publications/Publications/childrens-literature.epub diff --git a/Tests/LCPTests/Fixtures/daisy.lcpdf b/Tests/Publications/Publications/daisy.lcpdf similarity index 100% rename from Tests/LCPTests/Fixtures/daisy.lcpdf rename to Tests/Publications/Publications/daisy.lcpdf diff --git a/Tests/LCPTests/Fixtures/daisy.pdf b/Tests/Publications/Publications/daisy.pdf similarity index 100% rename from Tests/LCPTests/Fixtures/daisy.pdf rename to Tests/Publications/Publications/daisy.pdf diff --git a/Tests/Publications/TestPublications.swift b/Tests/Publications/TestPublications.swift new file mode 100644 index 000000000..d17564c8c --- /dev/null +++ b/Tests/Publications/TestPublications.swift @@ -0,0 +1,29 @@ +// +// Copyright 2025 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// Provides access to shared test publication files. +public enum TestPublications { + /// Returns the resource bundle containing shared test publications. + public static let bundle = Bundle.module + + /// Returns a URL for the specified publication file. + /// + /// - Parameter filename: The filename with extension (e.g., "childrens-literature.epub"). + /// - Returns: A URL pointing to the publication file. + public static func url(for filename: String) -> URL { + let components = filename.split(separator: ".", maxSplits: 1) + let name = String(components[0]) + let ext = components.count > 1 ? String(components[1]) : nil + + guard let url = bundle.url(forResource: name, withExtension: ext, subdirectory: "Publications") else { + fatalError("Test publication '\(filename)' not found in TestPublications bundle") + } + + return url + } +} diff --git a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift index 6bd82269f..4b8812de7 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/BufferingResourceTests.swift @@ -5,6 +5,7 @@ // @testable import ReadiumShared +import TestPublications import XCTest class BufferingResourceTests: XCTestCase { @@ -99,7 +100,7 @@ class BufferingResourceTests: XCTestCase { } } - private let file = Fixtures(path: "Fetcher").url(for: "epub.epub") + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file) diff --git a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift index 8dbe5b7a4..cf3745bae 100644 --- a/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift +++ b/Tests/SharedTests/Toolkit/Data/Resource/TailCachingResourceTests.swift @@ -5,6 +5,7 @@ // @testable import ReadiumShared +import TestPublications import XCTest class TailCachingResourceTests: XCTestCase { @@ -50,7 +51,7 @@ class TailCachingResourceTests: XCTestCase { } } - private let file = Fixtures(path: "Fetcher").url(for: "epub.epub") + private let file = FileURL(url: TestPublications.url(for: "childrens-literature.epub"))! private lazy var data = try! Data(contentsOf: file.url) private lazy var resource = FileResource(file: file)