import UIKit import WebKit import XCTest @testable import Legado @MainActor final class WebReaderBridgeTests: XCTestCase { func testGeneratedRSSContentFollowsAppearanceWithoutReloading() async throws { let source = RssSource(sourceUrl: "https://appearance-regression.test", sourceName: "测试") let loaded = expectation(description: "HTML loaded") let surface = RSSWebReaderView( mode: .html(content: "

订阅正文

链接", baseURL: nil), source: source, onTextChange: { _ in loaded.fulfill() } ) let coordinator = surface.makeCoordinator() let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) webView.navigationDelegate = coordinator let window = UIWindow(frame: webView.frame) let controller = UIViewController() controller.overrideUserInterfaceStyle = .dark controller.view.addSubview(webView) window.rootViewController = controller window.makeKeyAndVisible() defer { RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator) window.isHidden = true } coordinator.installRulesAndLoad(mode: surface.mode, in: webView) await fulfillment(of: [loaded], timeout: 20) let appearanceScript = """ [matchMedia('(prefers-color-scheme:dark)').matches, getComputedStyle(document.body).backgroundColor, getComputedStyle(document.body).color, getComputedStyle(document.querySelector('a')).color, getComputedStyle(document.documentElement).colorScheme].map(String).join('|') """ let dark = try await webView.evaluateJavaScript(appearanceScript) XCTAssertEqual(dark as? String, "true|rgb(17, 17, 19)|rgb(242, 242, 242)|rgb(158, 203, 255)|light dark") _ = try await webView.evaluateJavaScript("window.appearanceMarker = 'preserved'") controller.overrideUserInterfaceStyle = .light window.layoutIfNeeded() XCTAssertEqual(webView.traitCollection.userInterfaceStyle, .light) var light = try await webView.evaluateJavaScript(appearanceScript) for _ in 0..<50 { if (light as? String)?.hasPrefix("false|") == true { break } try await Task.sleep(nanoseconds: 100_000_000) light = try await webView.evaluateJavaScript(appearanceScript) } XCTAssertEqual(light as? String, "false|rgb(252, 252, 252)|rgb(23, 23, 23)|rgb(0, 0, 238)|light dark") let marker = try await webView.evaluateJavaScript("window.appearanceMarker") XCTAssertEqual(marker as? String, "preserved") // A source may intentionally override the generated article palette. _ = try await webView.evaluateJavaScript("document.head.insertAdjacentHTML('beforeend', '')") let custom = try await webView.evaluateJavaScript("getComputedStyle(document.body).color") XCTAssertEqual(custom as? String, "rgb(120, 30, 40)") } func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws { let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试") let loaded = expectation(description: "HTML loaded") let surface = RSSWebReaderView(mode: .html(content: "

页面正文

", baseURL: source.sourceUrl), source: source, onTextChange: { _ in loaded.fulfill() }) let coordinator = surface.makeCoordinator() let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() configuration.userContentController.add(coordinator, name: "legado") configuration.userContentController.addUserScript(.init(source: coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: configuration) webView.navigationDelegate = coordinator webView.uiDelegate = coordinator let window = UIWindow(frame: webView.frame) let controller = UIViewController() controller.view.addSubview(webView) window.rootViewController = controller window.makeKeyAndVisible() defer { RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator) window.isHidden = true } coordinator.installRulesAndLoad(mode: surface.mode, in: webView) await fulfillment(of: [loaded], timeout: 20) let result = try await webView.evaluateJavaScript(""" java.put('key', '已保存'); java.setContent('

规则正文

'); [java.get('key'), java.getString('tag.p@text')].join('|'); """) XCTAssertEqual(result as? String, "已保存|规则正文") } } private actor RSSPaginationTransport: HTTPTransport { private var requests: [URL] = [] private var shouldFailSecondPage = true func data(for request: URLRequest) async throws -> HTTPResponse { let url = try XCTUnwrap(request.url) requests.append(url) if url.host == "unavailable.test" { throw URLError(.cannotConnectToHost) } let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? .first { $0.name == "page" }?.value ?? "1" if page == "2", shouldFailSecondPage { shouldFailSecondPage = false throw URLError(.networkConnectionLost) } let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}" return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) } func requestedPages() -> [String] { requests.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "page" }?.value } } func requestCount() -> Int { requests.count } } private actor RSSDelayedTransport: HTTPTransport { let pausedHost: String let pausedPage: String private var requestTotal = 0 private var pending: CheckedContinuation? init(host: String, page: String) { pausedHost = host; pausedPage = page } func data(for request: URLRequest) async throws -> HTTPResponse { let url = try XCTUnwrap(request.url) let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? .first { $0.name == "page" }?.value ?? "1" requestTotal += 1 if url.host == pausedHost, page == pausedPage { await withCheckedContinuation { continuation in pending = continuation } } let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}" return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) } func waitUntilPaused() async throws { for _ in 0..<500 { if pending != nil { return } try await Task.sleep(nanoseconds: 10_000_000) } throw URLError(.timedOut) } func resume() { pending?.resume(); pending = nil } func requestCount() -> Int { requestTotal } } @MainActor final class RSSLibraryRegressionTests: XCTestCase { func testUnavailableDatabaseDoesNotCrashOrPublishUnsavedImports() async throws { let blocked = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try Data("existing file".utf8).write(to: blocked) defer { try? FileManager.default.removeItem(at: blocked) } let model = RSSLibraryModel.live(databaseURL: blocked.appendingPathComponent("rss.sqlite3")) XCTAssertNotNil(model.errorMessage) await model.load() XCTAssertNotNil(model.errorMessage) let saved = await model.save(source(), replacing: nil) XCTAssertFalse(saved) await model.importText(#"{"sourceUrl":"https://feed.test","sourceName":"未保存的订阅"}"#) XCTAssertTrue(model.sources.isEmpty) XCTAssertFalse(model.isWorking) XCTAssertEqual(try Data(contentsOf: blocked), Data("existing file".utf8)) } private func source(host: String = "feed.test") -> RssSource { var source = RssSource(sourceUrl: "https://\(host)", sourceName: host) source.sortUrl = "https://\(host)/list?page={{page}}" source.ruleArticles = "$.items" source.ruleTitle = "$.title" source.ruleLink = "$.link" source.ruleNextPage = "PAGE" return source } func testFailedNextPageRetriesTheSamePageWithoutSkippingArticles() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) let transport = RSSPaginationTransport() let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) let feed = source() await model.refresh(feed) XCTAssertEqual(model.articles.map(\.title), ["Page 1"]) await model.loadMore(feed) XCTAssertNotNil(model.errorMessage) XCTAssertEqual(model.articles.map(\.title), ["Page 1"]) await model.loadMore(feed) let pages = await transport.requestedPages() XCTAssertEqual(pages, ["1", "2", "2"]) XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"]) } func testFailedSourceChangeDoesNotReusePreviousSourcesArticlesOrNextPage() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) let transport = RSSPaginationTransport() let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) await model.refresh(source()) let unavailable = source(host: "unavailable.test") await model.refresh(unavailable) XCTAssertTrue(model.articles.isEmpty) let count = await transport.requestCount() await model.loadMore(unavailable) let afterLoadMore = await transport.requestCount() XCTAssertEqual(afterLoadMore, count) } func testSlowPreviousSourceCannotReplaceTheCurrentFeed() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) let transport = RSSDelayedTransport(host: "slow.test", page: "1") let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) let slow = Task { await model.refresh(source(host: "slow.test")) } try await transport.waitUntilPaused() await model.refresh(source()) XCTAssertTrue(model.isWorking) await transport.resume() await slow.value XCTAssertEqual(model.articles.map(\.origin), ["https://feed.test"]) XCTAssertFalse(model.isWorking) } func testRepeatedLoadMoreWhileWaitingRequestsOnlyOnePage() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3")) let transport = RSSDelayedTransport(host: "feed.test", page: "2") let model = RSSLibraryModel(database: database, service: RSSService(transport: transport)) let feed = source() await model.refresh(feed) let pending = Task { await model.loadMore(feed) } try await transport.waitUntilPaused() await model.loadMore(feed) let count = await transport.requestCount() XCTAssertEqual(count, 2) await transport.resume() await pending.value XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"]) } }