Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 .
Expand Down
21 changes: 18 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ let package = Package(
),
.testTarget(
name: "ReadiumSharedTests",
dependencies: ["ReadiumShared"],
dependencies: [
"ReadiumShared",
"TestPublications",
],
path: "Tests/SharedTests",
resources: [
.copy("Fixtures"),
Expand Down Expand Up @@ -101,7 +104,10 @@ let package = Package(
.testTarget(
name: "ReadiumNavigatorTests",
dependencies: ["ReadiumNavigator"],
path: "Tests/NavigatorTests"
path: "Tests/NavigatorTests",
exclude: [
"UITests",
]
),

.target(
Expand Down Expand Up @@ -140,7 +146,7 @@ let package = Package(
// dependencies: ["ReadiumLCP"],
// path: "Tests/LCPTests",
// resources: [
// .copy("Fixtures"),
// .copy("../Fixtures"),
// ]
// ),

Expand Down Expand Up @@ -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"),
]
),
]
)
14 changes: 10 additions & 4 deletions Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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.
Expand All @@ -215,22 +224,19 @@ 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()
}
goToContinuations.removeAll()
}

@MainActor
private var goToContinuations: [CheckedContinuation<Void, Never>] = []

@discardableResult
Expand Down
21 changes: 16 additions & 5 deletions Sources/Navigator/EPUB/EPUBSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/Navigator/Toolkit/PaginationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions Tests/NavigatorTests/UITests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.xcodeproj
Original file line number Diff line number Diff line change
@@ -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<Self, AccessibilityAttachmentModifier> {
accessibilityIdentifier(id.rawValue)
}
}
80 changes: 80 additions & 0 deletions Tests/NavigatorTests/UITests/NavigatorTestHost/Container.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading