diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index c54712ad24da7..4835f18693392 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -2268,6 +2268,9 @@ EDF567A02C8B51DC00FDB09D /* SiteImageView in Frameworks */ = {isa = PBXBuildFile; productRef = EDF5679F2C8B51DC00FDB09D /* SiteImageView */; }; EDF567A22C8B51E100FDB09D /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = EDF567A12C8B51E100FDB09D /* Kingfisher */; }; EDFEE3F42DE670B8005ADE03 /* gleanProbes.xcfilelist in Resources */ = {isa = PBXBuildFile; fileRef = EDFEE3F32DE670B8005ADE03 /* gleanProbes.xcfilelist */; }; + EEB965112FBCCDFC00D6C232 /* PageRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB9650E2FBCCDFC00D6C232 /* PageRoute.swift */; }; + EEB965122FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB9650F2FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift */; }; + EEB965162FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB965132FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift */; }; F00CA4012F00000000000003 /* WorldCupMatchesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000004 /* WorldCupMatchesResponse.swift */; }; F00CA4012F00000000000005 /* WorldCupLiveResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000006 /* WorldCupLiveResponse.swift */; }; F00CA4012F00000000000007 /* WorldCupAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000008 /* WorldCupAPIClient.swift */; }; @@ -2277,9 +2280,9 @@ F00CA4012F00000000000013 /* WorldCupTeamsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000014 /* WorldCupTeamsResponse.swift */; }; F00CA4012F00000000000015 /* WorldCupLoadError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000016 /* WorldCupLoadError.swift */; }; F00CA4012F00000000000017 /* WorldCupBaseHostOverrideSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000018 /* WorldCupBaseHostOverrideSetting.swift */; }; - F00CA4012F00000000000020 /* WorldCupFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000021 /* WorldCupFeed.swift */; }; - F00CA4012F0000000000001E /* WorldCupPollIntervalOverrideSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */; }; F00CA4012F0000000000001C /* WorldCupPollingFetchStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F0000000000001D /* WorldCupPollingFetchStrategy.swift */; }; + F00CA4012F0000000000001E /* WorldCupPollIntervalOverrideSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */; }; + F00CA4012F00000000000020 /* WorldCupFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4012F00000000000021 /* WorldCupFeed.swift */; }; F00CA4022F00000000000001 /* WorldCupMatchesResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4022F00000000000002 /* WorldCupMatchesResponseTests.swift */; }; F00CA4022F00000000000003 /* WorldCupLiveResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4022F00000000000004 /* WorldCupLiveResponseTests.swift */; }; F00CA4022F00000000000005 /* WorldCupFetchStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00CA4022F00000000000006 /* WorldCupFetchStrategyTests.swift */; }; @@ -11660,6 +11663,9 @@ EE994F2590D706FC0ADD4B42 /* ur */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ur; path = ur.lproj/AuthenticationManager.strings; sourceTree = ""; }; EEA34C528147F2E7C3AB52C8 /* mr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mr; path = mr.lproj/Localizable.strings; sourceTree = ""; }; EEAB4DF28099E1BEA98A0B00 /* an */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = an; path = an.lproj/ClearPrivateDataConfirm.strings; sourceTree = ""; }; + EEB9650E2FBCCDFC00D6C232 /* PageRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageRoute.swift; sourceTree = ""; }; + EEB9650F2FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderModeSchemeHandler.swift; sourceTree = ""; }; + EEB965132FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderModeSchemeHandlerTests.swift; sourceTree = ""; }; EEE8476797612D908D23E6BC /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; EEF342E5A045117853806115 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; EF0444EEBEA6A45A3E0F654C /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/PrivateBrowsing.strings"; sourceTree = ""; }; @@ -11680,9 +11686,9 @@ F00CA4012F00000000000014 /* WorldCupTeamsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupTeamsResponse.swift; sourceTree = ""; }; F00CA4012F00000000000016 /* WorldCupLoadError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupLoadError.swift; sourceTree = ""; }; F00CA4012F00000000000018 /* WorldCupBaseHostOverrideSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupBaseHostOverrideSetting.swift; sourceTree = ""; }; - F00CA4012F00000000000021 /* WorldCupFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupFeed.swift; sourceTree = ""; }; - F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupPollIntervalOverrideSetting.swift; sourceTree = ""; }; F00CA4012F0000000000001D /* WorldCupPollingFetchStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupPollingFetchStrategy.swift; sourceTree = ""; }; + F00CA4012F0000000000001F /* WorldCupPollIntervalOverrideSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupPollIntervalOverrideSetting.swift; sourceTree = ""; }; + F00CA4012F00000000000021 /* WorldCupFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupFeed.swift; sourceTree = ""; }; F00CA4022F00000000000002 /* WorldCupMatchesResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupMatchesResponseTests.swift; sourceTree = ""; }; F00CA4022F00000000000004 /* WorldCupLiveResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupLiveResponseTests.swift; sourceTree = ""; }; F00CA4022F00000000000006 /* WorldCupFetchStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorldCupFetchStrategyTests.swift; sourceTree = ""; }; @@ -16855,6 +16861,31 @@ path = Sharing; sourceTree = ""; }; + EEB965102FBCCDFC00D6C232 /* SchemeHandler */ = { + isa = PBXGroup; + children = ( + EEB9650E2FBCCDFC00D6C232 /* PageRoute.swift */, + EEB9650F2FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift */, + ); + path = SchemeHandler; + sourceTree = ""; + }; + EEB965142FBCCE7B00D6C232 /* SchemeHandler */ = { + isa = PBXGroup; + children = ( + EEB965132FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift */, + ); + path = SchemeHandler; + sourceTree = ""; + }; + EEB965152FBCCE7B00D6C232 /* ReaderTests */ = { + isa = PBXGroup; + children = ( + EEB965142FBCCE7B00D6C232 /* SchemeHandler */, + ); + path = ReaderTests; + sourceTree = ""; + }; F8324A082649A188007E4BFA /* CredentialProvider */ = { isa = PBXGroup; children = ( @@ -16972,6 +17003,7 @@ F84B21D61A090F8100AAB793 /* ClientTests */ = { isa = PBXGroup; children = ( + EEB965152FBCCE7B00D6C232 /* ReaderTests */, C24A0D2E2F3A7E7200BF08B7 /* KeyChainAppAttestKeyIDStoreTests.swift */, 2151EF582F2AB962007B67A6 /* BrowserViewController */, 218457C42F22A3FA00B4FF23 /* Downloads */, @@ -17196,6 +17228,7 @@ F84B21F51A0910F600AAB793 /* Reader */ = { isa = PBXGroup; children = ( + EEB965102FBCCDFC00D6C232 /* SchemeHandler */, 2178A69E2914546D002EC290 /* Resources */, 2178A69D291453CC002EC290 /* View */, E4CD9E901A6897FB00318571 /* ReaderMode.swift */, @@ -19204,6 +19237,8 @@ 8A2DAD4B2CC02AA00067ECD0 /* LabelButtonHeaderView.swift in Sources */, 8A2DAD4D2CC02AA10067ECD0 /* LabelButtonHeaderCell.swift in Sources */, 8A9B7A8A2F1A120100ABCDEF /* NewsAffordanceHeaderView.swift in Sources */, + EEB965112FBCCDFC00D6C232 /* PageRoute.swift in Sources */, + EEB965122FBCCDFC00D6C232 /* ReaderModeSchemeHandler.swift in Sources */, 8A91D4112F7D6C7800A1B2C3 /* NewsTransitionHeaderCell.swift in Sources */, 8A91D4132F7D6C7900A1B2C3 /* StoryCategoryPickerView.swift in Sources */, 8AF347DE2CADD1B200624036 /* HomepageState.swift in Sources */, @@ -20263,6 +20298,7 @@ C28EA9822FA2554900AEC3AE /* WorldCupTelemetryTests.swift in Sources */, FF0003AB2F000002000BBBBB /* WorldCupMiddlewareTests.swift in Sources */, FF0003AD2F000004000BBBBB /* WorldCupCellFactoryTests.swift in Sources */, + EEB965162FBCCE7B00D6C232 /* ReaderModeSchemeHandlerTests.swift in Sources */, FF0004AB2F000002000BBBBB /* WorldCupSectionStateTests.swift in Sources */, FF0005AB2F000002000BBBBB /* MockWorldCupStore.swift in Sources */, EDC3D3552CB70A3F00C62DE3 /* OpenSearchEngineTests.swift in Sources */, diff --git a/firefox-ios/Client/Frontend/Reader/SchemeHandler/PageRoute.swift b/firefox-ios/Client/Frontend/Reader/SchemeHandler/PageRoute.swift new file mode 100644 index 0000000000000..1a479d42de8e9 --- /dev/null +++ b/firefox-ios/Client/Frontend/Reader/SchemeHandler/PageRoute.swift @@ -0,0 +1,41 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Shared +import WebEngine + +/// Serves the readermode page document at `readermode://app/page?url=`. +/// +/// This initial version validates the incoming URL parameters. Content rendering +/// (cache integration, readability extraction, error pages) will be added in +/// FXIOS-15783. +final class PageRoute: TinyRoute { + private let profile: Profile + + init(profile: Profile) { + self.profile = profile + } + + // Always erros out for now, will actually handle in next PR + func handle(url: URL, components: URLComponents) async throws -> TinyHTTPReply? { + _ = try extractArticleURL(from: components) + throw TinyRouterError.badResponse + } + + // MARK: - URL parsing + + private func extractArticleURL(from components: URLComponents) throws -> URL { + guard let raw = components.queryItems?.first(where: { $0.name == "url" })?.value else { + throw TinyRouterError.missingParam("url") + } + + guard let parsed = URL(string: raw), parsed.isWebPage(includeDataURIs: false) else { + throw TinyRouterError.invalidParam("url", raw) + } + + return parsed + } +} diff --git a/firefox-ios/Client/Frontend/Reader/SchemeHandler/ReaderModeSchemeHandler.swift b/firefox-ios/Client/Frontend/Reader/SchemeHandler/ReaderModeSchemeHandler.swift new file mode 100644 index 0000000000000..a9abb790049e1 --- /dev/null +++ b/firefox-ios/Client/Frontend/Reader/SchemeHandler/ReaderModeSchemeHandler.swift @@ -0,0 +1,137 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Shared +import WebEngine +import WebKit + +/// `ReaderModeSchemeHandler` defines a custom URL scheme handler for a `WKWebView` that +/// serves reader-mode pages and their assets. It replaces the legacy GCDWebServer-based +/// reader-mode routes. +/// +/// Request flow +/// ------------ +/// 1. The browser navigates a tab to `readermode://app/...`. +/// +/// 2. WebKit detects the `readermode://` scheme and creates a `WKURLSchemeTask`. +/// +/// 3. WebKit calls `ReaderModeSchemeHandler.webView(_:start:)` passing in the task. +/// +/// 4. The scheme handler: +/// - reads the URL from `urlSchemeTask.request` +/// - validates that the URL uses the correct scheme and host (`readermode` and `app`) +/// - forwards the URL to `router.route(_:)` (TinyRouter) +/// +/// 5. TinyRouter chooses a route handler based on the path. As the migration progresses +/// the registered routes will be: +/// - `/app/page` -> `PageRoute` +/// +/// 6. `send(_:for:to:)` converts the `TinyHTTPReply` into an `HTTPURLResponse` and body +/// and completes the `WKURLSchemeTask`. +/// +/// 7. WebKit renders the response in the tab. +final class ReaderModeSchemeHandler: NSObject, WKURLSchemeHandler { + // These are plain string constants and need to be readable from non-MainActor contexts + // (e.g. `PageRoute.buildReply`, which constructs the CSP off the main actor). The class + // itself is @MainActor by virtue of conforming to `WKURLSchemeHandler`, which would + // otherwise propagate isolation to these statics. + + /// The custom scheme this handler is responsible for. + nonisolated static let scheme = "readermode" + + /// The host this handler expects for all reader-mode requests. + nonisolated static let host = "app" + + /// Canonical base URL for the reader page. Callers that need to construct a reader-mode + /// URL (e.g. `URL.encodeReaderModeURL(_:)`) pass this in place of the legacy + /// `WebServer.sharedInstance.baseReaderModeURL()`. + nonisolated static let baseURL = "readermode://app/page" + + private let router: TinyRouter + private let logger: Logger + private var requestTasks = [ObjectIdentifier: Task]() + + init(profile: Profile, + logger: Logger = DefaultLogger.shared) { + // `StaticFileRoute` is the same one Translations uses — given the path it strips + // the leading slash, separates the resource name + extension, and looks the file up + // via `Bundle.main.url(forResource:withExtension:)`. That covers the legacy paths + // `Reader.html` already references (`/reader-mode/styles/Reader.css` and + // `/reader-mode/fonts/*.otf`) without any template edits. + self.router = TinyRouter() + .register("page", PageRoute(profile: profile)) + .setDefault(StaticFileRoute()) + self.logger = logger + super.init() + } + + /// Validates incoming requests and forwards them to the router. + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + let id = ObjectIdentifier(urlSchemeTask) + let requestTask = Task { // Closure gets implicit @MainActor since WKURLSchemeTask is annotated as such (cool!) + defer { requestTasks[id] = nil } + do { + let url = try validateRequest(urlSchemeTask) + try Task.checkCancellation() + + let reply = try await router.route(url) + try Task.checkCancellation() + + try send(reply, for: url, to: urlSchemeTask) + } catch is CancellationError { + self.logger.log("Reader-mode scheme task cancelled.", + level: .debug, + category: .library) + } catch { + urlSchemeTask.didFailWithError(mapError(error)) + self.logger.log("Reader-mode scheme task failed.", + level: .debug, + category: .library) + } + } + requestTasks[id] = requestTask + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + let id = ObjectIdentifier(urlSchemeTask) + requestTasks[id]?.cancel() + requestTasks[id] = nil + } + + /// Bridges a `TinyHTTPReply` into the `WKURLSchemeTask` callbacks. + private func send(_ reply: TinyHTTPReply, for url: URL, to task: WKURLSchemeTask) throws { + guard let httpResponse = reply.httpResponse else { + throw TinyRouterError.badResponse + } + task.didReceive(httpResponse) + task.didReceive(reply.body) + task.didFinish() + } + + /// Normalizes any thrown `Error` into a `TinyRouterError`. + private func mapError(_ error: Error) -> TinyRouterError { + if let tinyError = error as? TinyRouterError { + return tinyError + } + return .unknown(String(describing: error)) + } + + /// Validates an incoming request and returns a well-formed URL, + /// or throws a typed error if the request is not acceptable. + private func validateRequest(_ task: WKURLSchemeTask) throws -> URL { + guard let url = task.request.url else { throw TinyRouterError.badURL } + + guard url.scheme == Self.scheme else { + throw TinyRouterError.unsupportedScheme(expected: Self.scheme, found: url.scheme) + } + + guard url.host == Self.host else { + throw TinyRouterError.unsupportedHost(expected: Self.host, found: url.host) + } + + return url + } +} diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/ReaderTests/SchemeHandler/ReaderModeSchemeHandlerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/ReaderTests/SchemeHandler/ReaderModeSchemeHandlerTests.swift new file mode 100644 index 0000000000000..3dcd1736b68b7 --- /dev/null +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/ReaderTests/SchemeHandler/ReaderModeSchemeHandlerTests.swift @@ -0,0 +1,192 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import XCTest +@testable import Client +import WebKit + +@MainActor +final class ReaderModeSchemeHandlerTests: XCTestCase { + private var subject: ReaderModeSchemeHandler! + + override func setUp() async throws { + try await super.setUp() + DependencyHelperMock().bootstrapDependencies() + subject = ReaderModeSchemeHandler(profile: MockProfile()) + } + + override func tearDown() async throws { + subject = nil + DependencyHelperMock().reset() + try await super.tearDown() + } + + // MARK: - Request validation + + func test_start_wrongScheme_failsWithUnsupportedScheme() { + let task = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "http://app/page")!) + ) + let webView = makeWebView() + let failExpectation = expectation(description: "onFail called") + task.onFail = { failExpectation.fulfill() } + + subject.webView(webView, start: task) + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertTrue(task.receivedResponses.isEmpty) + XCTAssertTrue(task.receivedBodies.isEmpty) + XCTAssertEqual(task.finishCallCount, 0) + XCTAssertEqual(task.failedErrors.count, 1) + + let error = task.failedErrors.first as? TinyRouterError + XCTAssertEqual(error, .unsupportedScheme(expected: ReaderModeSchemeHandler.scheme, found: "http")) + } + + func test_start_wrongHost_failsWithUnsupportedHost() { + let task = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "readermode://wronghost/page")!) + ) + let webView = makeWebView() + let failExpectation = expectation(description: "onFail called") + task.onFail = { failExpectation.fulfill() } + + subject.webView(webView, start: task) + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertTrue(task.receivedResponses.isEmpty) + XCTAssertTrue(task.receivedBodies.isEmpty) + XCTAssertEqual(task.finishCallCount, 0) + XCTAssertEqual(task.failedErrors.count, 1) + + let error = task.failedErrors.first as? TinyRouterError + XCTAssertEqual(error, .unsupportedHost(expected: ReaderModeSchemeHandler.host, found: "wronghost")) + } + + func test_start_nilURL_failsWithBadURL() { + var request = URLRequest(url: URL(string: ReaderModeSchemeHandler.baseURL)!) + request.url = nil + let task = MockWKURLSchemeTask(request: request) + let webView = makeWebView() + let failExpectation = expectation(description: "onFail called") + task.onFail = { failExpectation.fulfill() } + + subject.webView(webView, start: task) + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertTrue(task.receivedResponses.isEmpty) + XCTAssertTrue(task.receivedBodies.isEmpty) + XCTAssertEqual(task.finishCallCount, 0) + XCTAssertEqual(task.failedErrors.count, 1) + XCTAssertEqual(task.failedErrors.first as? TinyRouterError, .badURL) + } + + // MARK: - Routing + + func test_start_unknownBundleResource_failsViaDefaultRoute() { + // Path doesn't match the "page" prefix, so it falls through to the default route + // (`StaticFileRoute`), which attempts `Bundle.main.url(forResource:withExtension:)` + // for a resource that does not exist and throws `TinyRouterError.badURL`. + let task = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "readermode://app/nonexistent.xyz")!) + ) + let webView = makeWebView() + let failExpectation = expectation(description: "onFail called") + task.onFail = { failExpectation.fulfill() } + + subject.webView(webView, start: task) + wait(for: [failExpectation], timeout: 1.0) + + XCTAssertTrue(task.receivedBodies.isEmpty) + XCTAssertEqual(task.finishCallCount, 0) + XCTAssertEqual(task.failedErrors.count, 1) + XCTAssertTrue(task.receivedResponses.isEmpty) + // `StaticFileRoute` throws `.badURL` on Bundle.main miss; the handler wraps + // unrecognized errors as `.unknown`, but TinyRouterError passes through directly. + let error = task.failedErrors.first as? TinyRouterError + XCTAssertEqual(error, .badURL) + } + + func test_start_incorrectURLComponents_failsWithExpectedErrors() { + let webView = makeWebView() + + // Wrong scheme + let schemeTask = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "https://app/page?url=https%3A%2F%2Fexample.com")!) + ) + let schemeExpectation = expectation(description: "wrong scheme fails") + schemeTask.onFail = { schemeExpectation.fulfill() } + subject.webView(webView, start: schemeTask) + wait(for: [schemeExpectation], timeout: 1.0) + XCTAssertEqual( + schemeTask.failedErrors.first as? TinyRouterError, + .unsupportedScheme(expected: "readermode", found: "https") + ) + + // Wrong host + let hostTask = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "readermode://badhost/page?url=https%3A%2F%2Fexample.com")!) + ) + let hostExpectation = expectation(description: "wrong host fails") + hostTask.onFail = { hostExpectation.fulfill() } + subject.webView(webView, start: hostTask) + wait(for: [hostExpectation], timeout: 1.0) + XCTAssertEqual( + hostTask.failedErrors.first as? TinyRouterError, + .unsupportedHost(expected: "app", found: "badhost") + ) + + // Wrong path + let pathTask = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "readermode://app/unknown")!) + ) + let pathExpectation = expectation(description: "wrong path fails") + pathTask.onFail = { pathExpectation.fulfill() } + subject.webView(webView, start: pathTask) + wait(for: [pathExpectation], timeout: 1.0) + let pathError = pathTask.failedErrors.first as? TinyRouterError + XCTAssertNotNil(pathError) + + // Missing url query param + let paramTask = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: "readermode://app/page")!) + ) + let paramExpectation = expectation(description: "missing param fails") + paramTask.onFail = { paramExpectation.fulfill() } + subject.webView(webView, start: paramTask) + wait(for: [paramExpectation], timeout: 1.0) + XCTAssertEqual( + paramTask.failedErrors.first as? TinyRouterError, + .missingParam("url") + ) + } + + func test_start_validURL_passesValidationAndReachesRoute() { + let url = "\(ReaderModeSchemeHandler.baseURL)?url=https%3A%2F%2Fexample.com%2Farticle" + let task = MockWKURLSchemeTask( + request: URLRequest(url: URL(string: url)!) + ) + let webView = makeWebView() + let failExpectation = expectation(description: "onFail called") + task.onFail = { failExpectation.fulfill() } + + subject.webView(webView, start: task) + wait(for: [failExpectation], timeout: 1.0) + + // PageRoute always throws an error in this PR, + // but if the request passed scheme/host validation then it wasn't rejected as + // unsupportedScheme, unsupportedHost, or badURL + // TODO: Update this test once PageRoute is properly implemented + let error = task.failedErrors.first as? TinyRouterError + XCTAssertNotEqual(error, .unsupportedScheme(expected: ReaderModeSchemeHandler.scheme, found: "readermode")) + XCTAssertNotEqual(error, .unsupportedHost(expected: ReaderModeSchemeHandler.host, found: "app")) + XCTAssertNotEqual(error, .badURL) + } + + // MARK: - Helpers + + private func makeWebView() -> WKWebView { + WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + } +}