import Foundation import XCTest @testable import RastrilloApple final class FakePathSource: NativePathSource, @unchecked Sendable { private let lock = NSLock() private var update: (@Sendable (NativePath) -> Void)? private(set) var cancelled = false func start(_ update: @escaping @Sendable (NativePath) -> Void) { lock.withLock { self.update = update } } func cancel() { lock.withLock { cancelled = true } } func send(_ status: NativePath.Status) { lock.withLock { update }?(NativePath(status: status)) } } @MainActor private final class Counter { var count = 0 var task: Task? init(_ connectivity: NativeConnectivity) { let changes = connectivity.changes task = Task { @MainActor [weak self] in for await _ in changes { self?.count += 1 } } } } @MainActor private func settle(until condition: () -> Bool, seconds: Double = 2) async { let deadline = Date().addingTimeInterval(seconds) while !condition() && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) } } @MainActor final class ConnectivityTests: XCTestCase { let origin = URL(string: "https://docs.example")! let other = URL(string: "https://sheets.example")! func testOfflineFromThePathOrFromAFailedRequest() async { let source = FakePathSource() let connectivity = NativeConnectivity(source: source) XCTAssertFalse(connectivity.isOffline(origin), "An unknown path must not block requests") source.send(.unsatisfied) await settle { connectivity.path?.status == .unsatisfied } XCTAssertTrue(connectivity.isOffline(origin)) XCTAssertTrue(connectivity.isOffline(other)) source.send(.satisfied) await settle { connectivity.isPathSatisfied } XCTAssertFalse(connectivity.isOffline(origin)) connectivity.record(.offline(.cannotConnectToHost), origin: origin) XCTAssertTrue(connectivity.isOffline(origin), "The path is up but that server did not answer") XCTAssertFalse(connectivity.isOffline(other), "One unreachable server says nothing about another") connectivity.record(.responded(status: 503), origin: origin) XCTAssertFalse(connectivity.isOffline(origin), "Any response proves the server is reachable") connectivity.record(.offline(.timedOut), origin: origin) source.send(.requiresConnection) await settle { connectivity.path?.status == .requiresConnection } source.send(.satisfied) await settle { connectivity.isPathSatisfied } XCTAssertNil(connectivity.serverReachable[origin], "A path change must forget what the old path learned") } func testChangesFireOnPathReachabilityAndForeground() async { let source = FakePathSource() let connectivity = NativeConnectivity(source: source) let counter = Counter(connectivity) defer { counter.task?.cancel() } source.send(.satisfied) await settle { counter.count == 1 } XCTAssertEqual(counter.count, 1, "A path change must fire") connectivity.record(.responded(status: 200), origin: origin) await settle { counter.count == 2 } XCTAssertEqual(counter.count, 2, "A server becoming reachable must wake waiting senders") connectivity.record(.responded(status: 200), origin: origin) connectivity.record(.offline(.timedOut), origin: origin) try? await Task.sleep(nanoseconds: 50_000_000) XCTAssertEqual(counter.count, 2, "Repeated responses and failures are not changes") connectivity.foregrounded() await settle { counter.count == 3 } XCTAssertEqual(counter.count, 3) } func testWaitEndsEarlyWhenConnectivityChanges() async { let source = FakePathSource() let connectivity = NativeConnectivity(source: source) source.send(.satisfied) await settle { connectivity.path != nil } let start = Date() let waiting = Task { await connectivity.wait(upTo: 30) } try? await Task.sleep(nanoseconds: 50_000_000) connectivity.foregrounded() let changed = await waiting.value XCTAssertLessThan(Date().timeIntervalSince(start), 2, "A change must cut the backoff short") XCTAssertTrue(changed, "The caller must learn that connectivity changed, to reset its backoff") let timed = Date() let elapsed = await connectivity.wait(upTo: 0.05) XCTAssertLessThan(Date().timeIntervalSince(timed), 1, "The delay must elapse on its own") XCTAssertFalse(elapsed, "An elapsed delay is not a change") } /// The battery rule: while the path is down, no timer runs at all. func testNoTimedAttemptsWhileThePathIsDown() async { let source = FakePathSource() let connectivity = NativeConnectivity(source: source) source.send(.unsatisfied) await settle { connectivity.path != nil } var returned = false let waiting = Task { @MainActor in await connectivity.wait(upTo: 0.01); returned = true } try? await Task.sleep(nanoseconds: 300_000_000) XCTAssertFalse(returned, "A backoff delay elapsed while the path was down") source.send(.satisfied) await settle { returned } XCTAssertTrue(returned, "A restored path must end the wait at once") waiting.cancel() } func testNativeHTTPReportsThroughTheObserver() async throws { let fd = socket(AF_INET, SOCK_STREAM, 0) var address = sockaddr_in() address.sin_family = sa_family_t(AF_INET) address.sin_addr.s_addr = inet_addr("127.0.0.1") var length = socklen_t(MemoryLayout.size) _ = withUnsafeMutablePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, length) == 0 ? getsockname(fd, $0, &length) : -1 } } close(fd) let closed = URL(string: "http://127.0.0.1:\(UInt16(bigEndian: address.sin_port))")! let connectivity = NativeConnectivity(source: FakePathSource()) let client = NativeHTTP(origin: closed, observer: connectivity.observer(for: closed)) _ = try? await client.responseDetails("/") await settle { connectivity.serverReachable[closed] != nil } XCTAssertEqual(connectivity.serverReachable[closed], false) XCTAssertTrue(connectivity.isOffline(closed)) } func testNetworkPathSourceDeliversAPath() async { let connectivity = NativeConnectivity(source: NativeNetworkPathSource()) await settle(until: { connectivity.path != nil }, seconds: 5) XCTAssertNotNil(connectivity.path, "NWPathMonitor never reported a path") } }