Merge branch 'feat/k1-typed-errors-transport' into main
Merge K1: typed native errors, NativeTransport, RastrilloTestSupport
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+910
−46
Package.swift+4 −1README.md+31 −0Sources/RastrilloApple/NativeClientError.swift+122 −0Sources/RastrilloApple/NativeHTTP.swift+57 −40Sources/RastrilloApple/NativeResponse.swift+20 −0Sources/RastrilloApple/NativeStreamLines.swift+4 −3Sources/RastrilloApple/NativeTransport.swift+63 −0Sources/RastrilloTestSupport/ScriptedTransport.swift+146 −0Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift+2 −0Tests/RastrilloAppleTests/HTTPTests.swift+47 −2Tests/RastrilloAppleTests/TypedErrorTests.swift+140 −0Tests/RastrilloTestSupportTests/ScriptedTransportTests.swift+177 −0Tests/RastrilloTestSupportTests/StreamParityTests.swift+97 −0
CI failed — run details
diff --git a/Package.swift b/Package.swift| index 22a3ee0..404051a 100644 |
| --- a/Package.swift |
| +++ b/Package.swift |
| @@ -7,14 +7,17 @@ let package = Package( |
| platforms: [.iOS(.v17), .macOS(.v14)], |
| products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"]), |
| .library(name: "RastrilloApple", targets: ["RastrilloApple"]), |
| - .library(name: "RastrilloMacUpdates", targets: ["RastrilloMacUpdates"])], |
| + .library(name: "RastrilloMacUpdates", targets: ["RastrilloMacUpdates"]), |
| + .library(name: "RastrilloTestSupport", targets: ["RastrilloTestSupport"])], |
| dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6")], |
| targets: [ |
| .target(name: "RastrilloNative"), |
| .target(name: "RastrilloApple"), |
| + .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple"]), |
| .target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]), |
| .testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]), |
| .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple"]), |
| .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]), |
| + .testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]), |
| ] |
| ) |
diff --git a/README.md b/README.md| index 57415fa..00ab329 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -77,6 +77,37 @@ origin and redirect restrictions. It requires HTTP 200 and a file URL, applies |
| owns that temporary export and must remove it after the save panel completes |
| or is cancelled. Unlike in-memory responses, downloads have no body-size cap. |
| +### Typed errors and `NativeTransport` (in-progress primitive) |
| + |
| +This is the first slice of the offline kit (spec: `docs/superpowers/specs/2026-09-23-offline-kit.md` |
| +on `spec/offline-kit`). It stays marked in progress until Sheets has adopted it. |
| + |
| +`NativeClientError` tells an offline failure apart from an ended session: |
| +`.offline(URLError.Code)` covers `notConnectedToInternet`, `networkConnectionLost`, |
| +`cannotFindHost`, `cannotConnectToHost`, `dnsLookupFailed`, `timedOut`, `dataNotAllowed` |
| +and `internationalRoamingOff`. Other transport failures, TLS among them, are |
| +`.transport(code)`, retried as transient and never shown as offline. Statuses map |
| +to `.unauthorized` (401 and 303), `.forbidden`, `.notFound`, `.conflict(body)`, |
| +`.tooLarge`, `.rateLimited(retryAfter:)` (429, or 503 with Retry-After), |
| +`.server(status:)` and `.rejected(status:body:)`. `retryClass` sorts every case into |
| +offline, transient, auth, permanent or conflict. `localizedDescription` keeps the |
| +sentences callers showed before. A cancelled request throws `CancellationError`, |
| +never URLSession's -999. |
| + |
| +`responseDetails` and `response` still return any status; `NativeResponse.requireSuccess()` |
| +throws its typed case. `request` and `stream` throw typed cases, so a 401 on a |
| +stream is `.unauthorized`. `NativeHTTP(origin:credential:observer:)` reports every |
| +response and every offline-class failure to the optional observer, so connectivity |
| +can be learned from real traffic. |
| + |
| +`NativeTransport` is the protocol `NativeHTTP` conforms to. Code that can outlive |
| +a session should take a supplier, `@MainActor () -> NativeTransport?`, rather than |
| +capture one client, because signing in again replaces it. The |
| +`RastrilloTestSupport` product ships `ScriptedTransport`, which answers each call |
| +from a script: a status and body, offline, a hang until cancelled, or "commit, |
| +then lose the response". Scripted streams are framed by the same byte-level parser |
| +as `NativeHTTP`. Use it in test targets in place of fixture servers. |
| + |
| `tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources` |
| and reads the minimum macOS version from the package manifest. A SwiftPM |
| consumer with resources should first locate its resource bundle with |
diff --git a/Sources/RastrilloApple/NativeClientError.swift b/Sources/RastrilloApple/NativeClientError.swift| new file mode 100644 |
| index 0000000..dac7cdd |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeClientError.swift |
| @@ -0,0 +1,122 @@ |
| +import Foundation |
| + |
| +/// Why a native request failed, typed so that a caller can tell "you are |
| +/// offline" from "your session has ended". Both used to arrive as the same |
| +/// English sentence, so an app holding unsent edits could only tell its user |
| +/// to sign out, which would have thrown those edits away. |
| +public enum NativeClientError: LocalizedError, Sendable, Equatable { |
| + /// Local validation text, such as an invalid server address. |
| + case message(String) |
| + /// The request never reached the server, or its answer never came back. |
| + /// See `offlineCodes` for exactly which transport failures count. |
| + case offline(URLError.Code) |
| + /// 401, and 303: `NoRedirects` surfaces the redirect to /signin as a status. |
| + case unauthorized |
| + case forbidden |
| + case notFound |
| + /// 409. The body is kept so the app can read the server's side of it. |
| + case conflict(Data) |
| + /// 413. |
| + case tooLarge |
| + /// 429, and a 503 that carries Retry-After. |
| + case rateLimited(retryAfter: TimeInterval?) |
| + /// Any other status outside 2xx. |
| + case rejected(status: Int, body: Data) |
| + /// 5xx. |
| + case server(status: Int) |
| + case invalidResponse |
| + /// A transport failure that does not mean "offline", such as a TLS |
| + /// failure. Reporting a bad certificate as offline would hide a real fault |
| + /// behind a banner that tells the person to wait for a connection. |
| + case transport(URLError.Code) |
| + |
| + public enum RetryClass: Sendable, Equatable { |
| + /// Wait for connectivity, then retry. |
| + case offline |
| + /// Retry with backoff. |
| + case transient |
| + /// Retrying cannot help until the person signs in again. |
| + case auth |
| + /// Retrying the same request cannot succeed. |
| + case permanent |
| + /// The app decides; the server kept its own version. |
| + case conflict |
| + } |
| + |
| + public var retryClass: RetryClass { |
| + switch self { |
| + case .offline: return .offline |
| + case .unauthorized: return .auth |
| + case .conflict: return .conflict |
| + case .rateLimited, .server, .transport: return .transient |
| + case .message, .forbidden, .notFound, .tooLarge, .rejected, .invalidResponse: return .permanent |
| + } |
| + } |
| + |
| + /// Today's strings, so existing callers that show `localizedDescription` |
| + /// read exactly as they did before the cases were typed. |
| + public var errorDescription: String? { |
| + switch self { |
| + case .message(let value): return value |
| + case .offline(let code), .transport(let code): return URLError(code).localizedDescription |
| + case .unauthorized: return "Your session has ended. Sign out and sign in again." |
| + case .notFound: return "This server does not support this native feature yet." |
| + case .forbidden: return Self.unconfirmed(403) |
| + case .conflict: return Self.unconfirmed(409) |
| + case .tooLarge: return Self.unconfirmed(413) |
| + case .rateLimited: return "The server is busy. Your changes have not been confirmed." |
| + case .rejected(let status, _), .server(let status): return Self.unconfirmed(status) |
| + case .invalidResponse: return "Invalid server response." |
| + } |
| + } |
| + |
| + private static func unconfirmed(_ status: Int) -> String { |
| + "The request could not be completed (\(status)). Your changes have not been confirmed." |
| + } |
| + |
| + /// Transport failures that mean the network, not the server, is missing. |
| + static let offlineCodes: Set<URLError.Code> = [ |
| + .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, |
| + .dnsLookupFailed, .timedOut, .dataNotAllowed, .internationalRoamingOff, |
| + ] |
| + |
| + /// Maps a URLSession failure to a typed case. A cancelled task surfaces as |
| + /// `CancellationError`, like every other cancelled Swift task: URLSession's |
| + /// own -999 would otherwise reach callers that match cancellation by type |
| + /// and read as a failure. Errors that are not URLErrors pass through untouched. |
| + static func classify(_ error: Error) -> Error { |
| + guard let failure = error as? URLError else { return error } |
| + if failure.code == .cancelled { return CancellationError() } |
| + return offlineCodes.contains(failure.code) ? NativeClientError.offline(failure.code) : NativeClientError.transport(failure.code) |
| + } |
| + |
| + /// The typed failure for an HTTP status, or nil for 2xx. |
| + static func forStatus(_ status: Int, body: Data, headers: [String: String], now: Date = Date()) -> NativeClientError? { |
| + switch status { |
| + case 200..<300: return nil |
| + case 401, 303: return .unauthorized |
| + case 403: return .forbidden |
| + case 404: return .notFound |
| + case 409: return .conflict(body) |
| + case 413: return .tooLarge |
| + case 429: return .rateLimited(retryAfter: retryAfter(headers["retry-after"], now: now)) |
| + case 503: |
| + if let header = headers["retry-after"] { return .rateLimited(retryAfter: retryAfter(header, now: now)) } |
| + return .server(status: status) |
| + case 500..<600: return .server(status: status) |
| + default: return .rejected(status: status, body: body) |
| + } |
| + } |
| + |
| + /// Retry-After is either delay-seconds or an HTTP-date (RFC 9110 §10.2.3). |
| + static func retryAfter(_ header: String?, now: Date) -> TimeInterval? { |
| + guard let text = header?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil } |
| + if let seconds = Int(text), seconds >= 0 { return TimeInterval(seconds) } |
| + let formatter = DateFormatter() |
| + formatter.locale = Locale(identifier: "en_US_POSIX") |
| + formatter.timeZone = TimeZone(identifier: "GMT") |
| + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" |
| + guard let date = formatter.date(from: text) else { return nil } |
| + return max(0, date.timeIntervalSince(now)) |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/NativeHTTP.swift b/Sources/RastrilloApple/NativeHTTP.swift| index f9422e2..748b925 100644 |
| --- a/Sources/RastrilloApple/NativeHTTP.swift |
| +++ b/Sources/RastrilloApple/NativeHTTP.swift |
| @@ -6,11 +6,6 @@ public struct NativeSession: Codable, Sendable { |
| public init(name: String, value: String) { self.name = name; self.value = value } |
| } |
| -public enum NativeClientError: LocalizedError { |
| - case message(String) |
| - public var errorDescription: String? { if case .message(let value) = self { return value }; return nil } |
| -} |
| - |
| private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable { |
| func urlSession(_ session: URLSession, task: URLSessionTask, |
| willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, |
| @@ -18,14 +13,19 @@ private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Se |
| } |
| /// Ephemeral transport: no HTTP cache, shared cookie jar, or off-origin redirects. |
| -public final class NativeHTTP: @unchecked Sendable { |
| +public final class NativeHTTP: NativeTransport, @unchecked Sendable { |
| public let origin: URL |
| public let credential: NativeSession? |
| private let session: URLSession |
| + private let observer: (@Sendable (NativeTransportOutcome) -> Void)? |
| - public init(origin: URL, credential: NativeSession? = nil) { |
| + /// `observer` hears every response that arrives and every offline-class |
| + /// failure, so connectivity can be learned from real traffic instead of a probe. |
| + public init(origin: URL, credential: NativeSession? = nil, |
| + observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) { |
| self.origin = origin |
| self.credential = credential |
| + self.observer = observer |
| let config = URLSessionConfiguration.ephemeral |
| config.httpShouldSetCookies = false |
| config.urlCache = nil |
| @@ -62,10 +62,32 @@ public final class NativeHTTP: @unchecked Sendable { |
| return request |
| } |
| - public func response(_ path: String, method: String = "GET", body: Data? = nil, |
| - contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> (Data, Int) { |
| - let result = try await responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) |
| - return (result.data, result.status) |
| + /// A raw URLError used to escape from here, so "offline" could not be told |
| + /// apart from any other failure. Every transport failure is typed on its way out. |
| + private func typed(_ error: Error) -> Error { |
| + let mapped = NativeClientError.classify(error) |
| + if case NativeClientError.offline(let code) = mapped { observer?(.offline(code)) } |
| + return mapped |
| + } |
| + |
| + private func arrived(_ response: URLResponse) throws -> HTTPURLResponse { |
| + guard let response = response as? HTTPURLResponse else { throw NativeClientError.invalidResponse } |
| + observer?(.responded(status: response.statusCode)) |
| + return response |
| + } |
| + |
| + private static func headers(_ response: HTTPURLResponse) -> [String: String] { |
| + var headers: [String: String] = [:] |
| + for (key, value) in response.allHeaderFields { |
| + if let key = key as? String, let value = value as? String { headers[key.lowercased()] = value } |
| + } |
| + return headers |
| + } |
| + |
| + /// Reads the next byte, typing a connection lost mid-body as offline. The |
| + /// consumer's own callbacks run outside this, so their errors pass untouched. |
| + private func next(_ iterator: inout URLSession.AsyncBytes.AsyncIterator) async throws -> UInt8? { |
| + do { return try await iterator.next() } catch { throw typed(error) } |
| } |
| /// An explicit export to a new file, streamed by URLSession without retaining |
| @@ -74,10 +96,13 @@ public final class NativeHTTP: @unchecked Sendable { |
| public func download(_ path: String, to destination: URL) async throws { |
| guard destination.isFileURL else { throw NativeClientError.message("Invalid export destination.") } |
| let request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") |
| - let (temporary, response) = try await session.download(for: request) |
| + let temporary: URL, received: URLResponse |
| + do { (temporary, received) = try await session.download(for: request) } catch { throw typed(error) } |
| defer { try? FileManager.default.removeItem(at: temporary) } |
| try Task.checkCancellation() |
| - guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { |
| + let response = try arrived(received) |
| + if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure } |
| + guard response.statusCode == 200 else { |
| throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") |
| } |
| try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) |
| @@ -85,38 +110,41 @@ public final class NativeHTTP: @unchecked Sendable { |
| } |
| /// Exposes protocol cursors (for example Docs-Seq) without sharing a cookie jar. |
| - public func responseDetails(_ path: String, method: String = "GET", body: Data? = nil, |
| - contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> NativeResponse { |
| + /// Returns any status; see `NativeResponse.requireSuccess()`. |
| + public func responseDetails(_ path: String, method: String, body: Data?, |
| + contentType: String, maxBytes: Int) async throws -> NativeResponse { |
| guard maxBytes > 0, maxBytes <= 256 * 1024 * 1024 else { |
| throw NativeClientError.message("Invalid response size limit.") |
| } |
| let request = try makeRequest(path, method: method, body: body, contentType: contentType) |
| - let (bytes, response) = try await session.bytes(for: request) |
| + let bytes: URLSession.AsyncBytes, received: URLResponse |
| + do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) } |
| defer { bytes.task.cancel() } |
| - guard let response = response as? HTTPURLResponse else { throw NativeClientError.message("Invalid server response.") } |
| + let response = try arrived(received) |
| var data = Data() |
| - for try await byte in bytes { |
| + var iterator = bytes.makeAsyncIterator() |
| + while let byte = try await next(&iterator) { |
| try Task.checkCancellation() |
| guard data.count < maxBytes else { throw NativeClientError.message("The server response is too large.") } |
| data.append(byte) |
| } |
| - var headers: [String: String] = [:] |
| - for (key, value) in response.allHeaderFields { |
| - if let key = key as? String, let value = value as? String { headers[key.lowercased()] = value } |
| - } |
| - return NativeResponse(data: data, status: response.statusCode, headers: headers) |
| + return NativeResponse(data: data, status: response.statusCode, headers: Self.headers(response)) |
| } |
| /// Delivers bounded UTF-8 SSE lines, including blank event delimiters. Callers |
| /// own event parsing and reconnect cursors; a clean EOF is never a saved edit. |
| - public func stream(_ path: String, onOpen: @Sendable () async throws -> Void = {}, |
| + public func stream(_ path: String, onOpen: @Sendable () async throws -> Void, |
| onLine: @Sendable (String) async throws -> Void) async throws { |
| var request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json") |
| request.setValue("text/event-stream", forHTTPHeaderField: "Accept") |
| - let (bytes, response) = try await session.bytes(for: request) |
| + let bytes: URLSession.AsyncBytes, received: URLResponse |
| + do { (bytes, received) = try await session.bytes(for: request) } catch { throw typed(error) } |
| defer { bytes.task.cancel() } |
| - guard let response = response as? HTTPURLResponse, |
| - response.statusCode == 200, |
| + let response = try arrived(received) |
| + // A 401 on the stream is an ended session, not a generic refusal: the |
| + // app must not advise signing out while it holds unsent edits. |
| + if let failure = NativeClientError.forStatus(response.statusCode, body: Data(), headers: Self.headers(response)) { throw failure } |
| + guard response.statusCode == 200, |
| response.value(forHTTPHeaderField: "Content-Type")?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() == "text/event-stream" else { |
| throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.") |
| } |
| @@ -126,23 +154,12 @@ public final class NativeHTTP: @unchecked Sendable { |
| try Task.checkCancellation() |
| try await onOpen() |
| var parser = NativeStreamLines() |
| - for try await byte in bytes { |
| + var iterator = bytes.makeAsyncIterator() |
| + while let byte = try await next(&iterator) { |
| try Task.checkCancellation() |
| if let line = try parser.append(byte) { try await onLine(line) } |
| } |
| // An unterminated event is not delivered at EOF; the next snapshot is the |
| // authority after reconnection, rather than an incomplete network frame. |
| } |
| - |
| - public func request<T: Decodable>(_ path: String, method: String = "GET", body: Data? = nil, |
| - contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> T { |
| - let (data, status) = try await response(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) |
| - guard (200..<300).contains(status) else { |
| - if status == 401 || status == 303 { throw NativeClientError.message("Your session has ended. Sign out and sign in again.") } |
| - if status == 404 { throw NativeClientError.message("This server does not support this native feature yet.") } |
| - throw NativeClientError.message("The request could not be completed (\(status)). Your changes have not been confirmed.") |
| - } |
| - do { return try JSONDecoder().decode(T.self, from: data) } |
| - catch { throw NativeClientError.message("The server returned an unsupported response.") } |
| - } |
| } |
diff --git a/Sources/RastrilloApple/NativeResponse.swift b/Sources/RastrilloApple/NativeResponse.swift| index 4b174cd..acbc0f3 100644 |
| --- a/Sources/RastrilloApple/NativeResponse.swift |
| +++ b/Sources/RastrilloApple/NativeResponse.swift |
| @@ -5,5 +5,25 @@ public struct NativeResponse: Sendable { |
| public let status: Int |
| public let headers: [String: String] |
| + /// Header names are stored lowercased so that `header(_:)` is case-insensitive. |
| + public init(data: Data, status: Int, headers: [String: String] = [:]) { |
| + self.data = data |
| + self.status = status |
| + var lowered: [String: String] = [:] |
| + for (key, value) in headers { lowered[key.lowercased()] = value } |
| + self.headers = lowered |
| + } |
| + |
| public func header(_ name: String) -> String? { headers[name.lowercased()] } |
| + |
| + /// The typed failure this status means, or nil for 2xx. |
| + public var failure: NativeClientError? { NativeClientError.forStatus(status, body: data, headers: headers) } |
| + |
| + /// Throws the typed case for any status outside 2xx, so a caller that |
| + /// compared codes by hand can hand the decision to one mapping. |
| + @discardableResult |
| + public func requireSuccess() throws -> NativeResponse { |
| + if let failure { throw failure } |
| + return self |
| + } |
| } |
diff --git a/Sources/RastrilloApple/NativeStreamLines.swift b/Sources/RastrilloApple/NativeStreamLines.swift| index 9b9217a..4ad8560 100644 |
| --- a/Sources/RastrilloApple/NativeStreamLines.swift |
| +++ b/Sources/RastrilloApple/NativeStreamLines.swift |
| @@ -1,7 +1,8 @@ |
| import Foundation |
| /// Bound before decoding: AsyncBytes.lines can allocate an unbounded line. |
| -struct NativeStreamLines { |
| +/// Package access so ScriptedTransport frames streams exactly as NativeHTTP does. |
| +package struct NativeStreamLines { |
| private var buffer = Data() |
| private var eventBytes = 0 |
| private var afterCR = false |
| @@ -9,12 +10,12 @@ struct NativeStreamLines { |
| let lineLimit: Int |
| let eventLimit: Int |
| - init(lineLimit: Int = 256 * 1024, eventLimit: Int = 1024 * 1024) { |
| + package init(lineLimit: Int = 256 * 1024, eventLimit: Int = 1024 * 1024) { |
| self.lineLimit = lineLimit |
| self.eventLimit = eventLimit |
| } |
| - mutating func append(_ byte: UInt8) throws -> String? { |
| + package mutating func append(_ byte: UInt8) throws -> String? { |
| if afterCR { |
| afterCR = false |
| if byte == 10 { return nil } |
diff --git a/Sources/RastrilloApple/NativeTransport.swift b/Sources/RastrilloApple/NativeTransport.swift| new file mode 100644 |
| index 0000000..85935e7 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeTransport.swift |
| @@ -0,0 +1,63 @@ |
| +import Foundation |
| + |
| +/// What a native client needs from the network. `NativeHTTP` is the real one; |
| +/// `RastrilloTestSupport.ScriptedTransport` answers from a script, so offline, |
| +/// lost responses and ended sessions can be tested without a fixture server. |
| +/// |
| +/// Anything that can outlive a session should take a supplier, |
| +/// `@MainActor () -> NativeTransport?`, rather than capturing one transport: |
| +/// signing in again replaces the transport under it. |
| +/// |
| +/// The requirements take every argument. The shorter forms, with defaults, |
| +/// are extensions below; they have different signatures from the |
| +/// requirements, so no conformer can pick one up as a default |
| +/// implementation that calls itself. |
| +public protocol NativeTransport: Sendable { |
| + var origin: URL { get } |
| + /// Returns any status. Throws `NativeClientError.offline` when the request |
| + /// never reached the server or its answer never came back. |
| + func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse |
| + /// Throws the typed case for a refused stream: a 401 reads as `.unauthorized`. |
| + func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws |
| + func download(_ path: String, to destination: URL) async throws |
| +} |
| + |
| +/// The default response bound, 4 MiB. |
| +public let nativeDefaultMaxBytes = 4 * 1024 * 1024 |
| + |
| +public extension NativeTransport { |
| + func responseDetails(_ path: String, method: String = "GET", body: Data? = nil, |
| + contentType: String = "application/json") async throws -> NativeResponse { |
| + try await responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: nativeDefaultMaxBytes) |
| + } |
| + |
| + func responseDetails(_ path: String, method: String = "GET", body: Data? = nil, maxBytes: Int) async throws -> NativeResponse { |
| + try await responseDetails(path, method: method, body: body, contentType: "application/json", maxBytes: maxBytes) |
| + } |
| + |
| + func response(_ path: String, method: String = "GET", body: Data? = nil, |
| + contentType: String = "application/json", maxBytes: Int = nativeDefaultMaxBytes) async throws -> (Data, Int) { |
| + let result = try await responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) |
| + return (result.data, result.status) |
| + } |
| + |
| + /// Decodes a 2xx body; any other status throws its typed case. |
| + func request<T: Decodable>(_ path: String, method: String = "GET", body: Data? = nil, |
| + contentType: String = "application/json", maxBytes: Int = nativeDefaultMaxBytes) async throws -> T { |
| + let result = try await responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) |
| + try result.requireSuccess() |
| + do { return try JSONDecoder().decode(T.self, from: result.data) } |
| + catch { throw NativeClientError.message("The server returned an unsupported response.") } |
| + } |
| + |
| + func stream(_ path: String, onLine: @Sendable (String) async throws -> Void) async throws { |
| + try await stream(path, onOpen: {}, onLine: onLine) |
| + } |
| +} |
| + |
| +/// What `NativeHTTP` tells its outcome observer. A response of any status |
| +/// proves the server is reachable; an offline-class failure suggests it is not. |
| +public enum NativeTransportOutcome: Sendable, Equatable { |
| + case responded(status: Int) |
| + case offline(URLError.Code) |
| +} |
diff --git a/Sources/RastrilloTestSupport/ScriptedTransport.swift b/Sources/RastrilloTestSupport/ScriptedTransport.swift| new file mode 100644 |
| index 0000000..13766ec |
| --- /dev/null |
| +++ b/Sources/RastrilloTestSupport/ScriptedTransport.swift |
| @@ -0,0 +1,146 @@ |
| +import Foundation |
| +import RastrilloApple |
| + |
| +/// A `NativeTransport` that answers each call from a script, in order. |
| +/// |
| +/// It replaces the Python fixture servers apps spin up to reach an offline |
| +/// or ended-session path: the failure is one line of script instead of a |
| +/// process, and "the server applied it but the answer was lost" can be said |
| +/// at all. Statuses go through the same mapping as `NativeHTTP`, so a |
| +/// scripted 401 fails `request` with `.unauthorized` exactly as a real one would. |
| +/// |
| +/// The script is either a queue of steps, answered in call order, or a |
| +/// responder that answers each call by what it asks for, for callers whose |
| +/// requests race (a stream and a save, say). A call with no step left, or |
| +/// one the responder declines, throws `ScriptedTransport.Unscripted`, so a |
| +/// test that makes a request it did not plan for fails loudly. |
| +public final class ScriptedTransport: NativeTransport, @unchecked Sendable { |
| + public enum Step: Sendable { |
| + /// The server answers. For `stream`, a 200 body is framed into lines |
| + /// exactly as `NativeHTTP` frames the network; for `download`, a 200 |
| + /// body is the file. |
| + case respond(status: Int, body: Data = Data(), headers: [String: String] = [:]) |
| + /// The request never reaches the server. |
| + case offline(URLError.Code = .notConnectedToInternet) |
| + /// No answer until the calling task is cancelled; then `CancellationError`. |
| + case hang |
| + /// The server applies the request (it is recorded in `committed`), then |
| + /// the connection drops before the answer arrives. |
| + case commitThenLoseResponse(URLError.Code = .networkConnectionLost) |
| + /// Any other failure, thrown as given. |
| + case fail(any Error & Sendable) |
| + } |
| + |
| + public struct Call: Sendable, Equatable { |
| + public enum Kind: Sendable, Equatable { case request, stream, download } |
| + public let kind: Kind |
| + public let path: String |
| + public let method: String |
| + public let body: Data? |
| + public init(kind: Kind, path: String, method: String, body: Data?) { |
| + self.kind = kind; self.path = path; self.method = method; self.body = body |
| + } |
| + } |
| + |
| + public struct Unscripted: LocalizedError, Sendable { |
| + public let call: Call |
| + public var errorDescription: String? { "ScriptedTransport has no step left for \(call.method) \(call.path)." } |
| + } |
| + |
| + public let origin: URL |
| + private let lock = NSLock() |
| + private var steps: [Step] |
| + private let responder: (@Sendable (Call) -> Step?)? |
| + private var recordedCalls: [Call] = [] |
| + private var recordedCommits: [Call] = [] |
| + |
| + public init(origin: URL = URL(string: "https://scripted.invalid")!, _ steps: [Step] = []) { |
| + self.origin = origin |
| + self.steps = steps |
| + self.responder = nil |
| + } |
| + |
| + public init(origin: URL = URL(string: "https://scripted.invalid")!, responder: @escaping @Sendable (Call) -> Step?) { |
| + self.origin = origin |
| + self.steps = [] |
| + self.responder = responder |
| + } |
| + |
| + public func append(_ more: Step...) { lock.withLock { steps.append(contentsOf: more) } } |
| + /// Every call made, in order, including ones that failed. |
| + public var calls: [Call] { lock.withLock { recordedCalls } } |
| + /// The calls the scripted server applied: every 2xx answer, and every |
| + /// `commitThenLoseResponse`, whose caller saw only a failure. |
| + public var committed: [Call] { lock.withLock { recordedCommits } } |
| + public var remainingSteps: Int { lock.withLock { steps.count } } |
| + |
| + private func take(_ call: Call) throws -> Step { |
| + if let responder { |
| + lock.withLock { recordedCalls.append(call) } |
| + guard let step = responder(call) else { throw Unscripted(call: call) } |
| + return step |
| + } |
| + return try lock.withLock { |
| + recordedCalls.append(call) |
| + guard !steps.isEmpty else { throw Unscripted(call: call) } |
| + return steps.removeFirst() |
| + } |
| + } |
| + |
| + private func commit(_ call: Call) { lock.withLock { recordedCommits.append(call) } } |
| + |
| + /// Resolves a step to a response, or throws the failure it scripts. |
| + private func answer(_ call: Call) async throws -> NativeResponse { |
| + switch try take(call) { |
| + case .respond(let status, let body, let headers): |
| + if (200..<300).contains(status) { commit(call) } |
| + return NativeResponse(data: body, status: status, headers: headers) |
| + case .offline(let code): |
| + throw NativeClientError.offline(code) |
| + case .hang: |
| + while true { try await Task.sleep(nanoseconds: 60_000_000_000) } |
| + case .commitThenLoseResponse(let code): |
| + commit(call) |
| + throw NativeClientError.offline(code) |
| + case .fail(let error): |
| + throw error |
| + } |
| + } |
| + |
| + public func responseDetails(_ path: String, method: String, body: Data?, |
| + contentType: String, maxBytes: Int) async throws -> NativeResponse { |
| + let response = try await answer(Call(kind: .request, path: path, method: method, body: body)) |
| + guard response.data.count <= maxBytes else { throw NativeClientError.message("The server response is too large.") } |
| + return response |
| + } |
| + |
| + public func stream(_ path: String, onOpen: @Sendable () async throws -> Void, |
| + onLine: @Sendable (String) async throws -> Void) async throws { |
| + let response = try await answer(Call(kind: .stream, path: path, method: "GET", body: nil)) |
| + try response.requireSuccess() |
| + guard response.status == 200 else { |
| + throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.") |
| + } |
| + try await onOpen() |
| + // The same byte-level framing as NativeHTTP: CR, LF and CRLF all end a |
| + // line, limits and invalid UTF-8 throw, and an unterminated final line |
| + // is never delivered. |
| + var parser = NativeStreamLines() |
| + for byte in response.data { |
| + try Task.checkCancellation() |
| + if let line = try parser.append(byte) { try await onLine(line) } |
| + } |
| + } |
| + |
| + public func download(_ path: String, to destination: URL) async throws { |
| + let response = try await answer(Call(kind: .download, path: path, method: "GET", body: nil)) |
| + try response.requireSuccess() |
| + guard response.status == 200 else { |
| + throw NativeClientError.message("The export could not be completed. Refresh your session and try again.") |
| + } |
| + guard !FileManager.default.fileExists(atPath: destination.path) else { |
| + throw CocoaError(.fileWriteFileExists) |
| + } |
| + try response.data.write(to: destination, options: .withoutOverwriting) |
| + } |
| +} |
diff --git a/Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift b/Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift| index fc1cbe8..ebc6373 100644 |
| --- a/Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift |
| +++ b/Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift |
| @@ -163,6 +163,8 @@ server.handle_request() |
| XCTFail("A cancelled stream completed successfully") |
| } catch { |
| XCTAssertTrue(Task.isCancelled) |
| + // Callers match cancellation by type; URLSession's -999 must not leak. |
| + XCTAssertTrue(error is CancellationError, "Expected CancellationError, got \(error)") |
| } |
| finished.fulfill() |
| } |
diff --git a/Tests/RastrilloAppleTests/HTTPTests.swift b/Tests/RastrilloAppleTests/HTTPTests.swift| index f8d5fa5..b387642 100644 |
| --- a/Tests/RastrilloAppleTests/HTTPTests.swift |
| +++ b/Tests/RastrilloAppleTests/HTTPTests.swift |
| @@ -4,6 +4,13 @@ import Darwin |
| import XCTest |
| @testable import RastrilloApple |
| +private final class ReceivedOutcomes: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var recorded: [NativeTransportOutcome] = [] |
| + func append(_ outcome: NativeTransportOutcome) { lock.withLock { recorded.append(outcome) } } |
| + var values: [NativeTransportOutcome] { lock.withLock { recorded } } |
| +} |
| + |
| private actor ReceivedLines { |
| var values: [String] = [] |
| func append(_ line: String) { values.append(line) } |
| @@ -17,7 +24,7 @@ final class HTTPTests: XCTestCase { |
| if process.isRunning { kill(process.processIdentifier, SIGKILL) } |
| } |
| - private func fixture() throws -> (Process, NativeHTTP) { |
| + private func fixture(observer: (@Sendable (NativeTransportOutcome) -> Void)? = nil) throws -> (Process, NativeHTTP) { |
| let process = Process() |
| process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") |
| process.arguments = ["-u", "-c", #""" |
| @@ -30,6 +37,14 @@ class Handler(http.server.BaseHTTPRequestHandler): |
| self.send_header('Location', '/unexpected-follow') |
| self.end_headers() |
| return |
| + if self.path.startswith('/status/'): |
| + body = b'refused' |
| + self.send_response(int(self.path.split('/')[2])) |
| + self.send_header('Content-Length', str(len(body))) |
| + self.send_header('Retry-After', '9') |
| + self.end_headers() |
| + self.wfile.write(body) |
| + return |
| if self.path == '/stream': |
| body = b'event: update\r\ndata: 42\r\n\r\n' |
| content_type = 'text/event-stream; charset=utf-8' |
| @@ -62,7 +77,8 @@ server.serve_forever() |
| process.terminate() |
| throw NativeClientError.message("The isolated HTTP fixture could not start.") |
| } |
| - return (process, NativeHTTP(origin: origin, credential: NativeSession(name: "rastrillo_session", value: "fixture"))) |
| + return (process, NativeHTTP(origin: origin, credential: NativeSession(name: "rastrillo_session", value: "fixture"), |
| + observer: observer)) |
| } |
| func testResponseLimitHeadersAndOrigin() async throws { |
| @@ -79,6 +95,35 @@ server.serve_forever() |
| } catch { XCTAssertTrue(error.localizedDescription.contains("too large")) } |
| } |
| + /// Real statuses from a real server reach the caller typed, through every |
| + /// entry point, and each arrival tells the observer the server is reachable. |
| + func testStatusesAreTypedAndObserved() async throws { |
| + let outcomes = ReceivedOutcomes() |
| + let (server, client) = try fixture(observer: { outcomes.append($0) }) |
| + defer { stop(server) } |
| + struct Empty: Decodable {} |
| + do { |
| + let _: Empty = try await client.request("/status/401") |
| + XCTFail("A 401 decoded") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } |
| + do { |
| + try await client.stream("/status/401") { _ in XCTFail("A refused stream delivered a line") } |
| + XCTFail("A 401 stream opened") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } |
| + do { |
| + let _: Empty = try await client.request("/status/409") |
| + XCTFail("A 409 decoded") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .conflict(Data("refused".utf8))) } |
| + do { |
| + let _: Empty = try await client.request("/status/503") |
| + XCTFail("A 503 decoded") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .rateLimited(retryAfter: 9)) } |
| + let details = try await client.responseDetails("/status/404") |
| + XCTAssertEqual(details.status, 404, "responseDetails must keep returning the status") |
| + XCTAssertThrowsError(try details.requireSuccess()) { XCTAssertEqual($0 as? NativeClientError, .notFound) } |
| + XCTAssertEqual(outcomes.values, [401, 401, 409, 503, 404].map { .responded(status: $0) }) |
| + } |
| + |
| func testRedirectIsReturnedWithoutFollowing() async throws { |
| let (server, client) = try fixture() |
| defer { stop(server) } |
diff --git a/Tests/RastrilloAppleTests/TypedErrorTests.swift b/Tests/RastrilloAppleTests/TypedErrorTests.swift| new file mode 100644 |
| index 0000000..10f7bad |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/TypedErrorTests.swift |
| @@ -0,0 +1,140 @@ |
| +import Foundation |
| +import Darwin |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +private final class Outcomes: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var recorded: [NativeTransportOutcome] = [] |
| + func append(_ outcome: NativeTransportOutcome) { lock.withLock { recorded.append(outcome) } } |
| + var values: [NativeTransportOutcome] { lock.withLock { recorded } } |
| +} |
| + |
| +final class TypedErrorTests: XCTestCase { |
| + /// A loopback port nothing listens on: bound by the kernel, then released. |
| + private func closedPort() throws -> UInt16 { |
| + let fd = socket(AF_INET, SOCK_STREAM, 0) |
| + guard fd >= 0 else { throw NativeClientError.message("socket failed") } |
| + defer { close(fd) } |
| + var address = sockaddr_in() |
| + address.sin_family = sa_family_t(AF_INET) |
| + address.sin_addr.s_addr = inet_addr("127.0.0.1") |
| + address.sin_port = 0 |
| + var length = socklen_t(MemoryLayout<sockaddr_in>.size) |
| + let bound = withUnsafeMutablePointer(to: &address) { |
| + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer -> Int32 in |
| + guard Darwin.bind(fd, pointer, length) == 0 else { return -1 } |
| + return getsockname(fd, pointer, &length) |
| + } |
| + } |
| + guard bound == 0 else { throw NativeClientError.message("bind failed") } |
| + return UInt16(bigEndian: address.sin_port) |
| + } |
| + |
| + /// The machine is online; only the server is missing. That still reads as |
| + /// `.offline`, never as an ended session or a generic failure. |
| + func testClosedPortIsOfflineForEveryCall() async throws { |
| + let port = try closedPort() |
| + let outcomes = Outcomes() |
| + let client = NativeHTTP(origin: URL(string: "http://127.0.0.1:\(port)")!, |
| + observer: { outcomes.append($0) }) |
| + let offline = NativeClientError.offline(.cannotConnectToHost) |
| + do { |
| + _ = try await client.responseDetails("/d/1") |
| + XCTFail("A closed port answered") |
| + } catch { XCTAssertEqual(error as? NativeClientError, offline, "Got \(error)") } |
| + do { |
| + struct Empty: Decodable {} |
| + let _: Empty = try await client.request("/d/1", method: "POST", body: Data("{}".utf8)) |
| + XCTFail("A closed port answered") |
| + } catch { XCTAssertEqual(error as? NativeClientError, offline, "Got \(error)") } |
| + do { |
| + try await client.stream("/d/1/stream") { _ in XCTFail("A closed port streamed") } |
| + XCTFail("A closed port streamed") |
| + } catch { XCTAssertEqual(error as? NativeClientError, offline, "Got \(error)") } |
| + do { |
| + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| + try await client.download("/export", to: file) |
| + XCTFail("A closed port exported") |
| + } catch { XCTAssertEqual(error as? NativeClientError, offline, "Got \(error)") } |
| + XCTAssertEqual(offline.retryClass, .offline) |
| + XCTAssertEqual(outcomes.values, Array(repeating: .offline(.cannotConnectToHost), count: 4)) |
| + } |
| + |
| + /// A server that accepts the connection and never answers, so the request |
| + /// is cancelled while URLSession is still waiting for response headers. |
| + func testCancelledRequestThrowsCancellationError() async throws { |
| + let fd = socket(AF_INET, SOCK_STREAM, 0) |
| + XCTAssertGreaterThanOrEqual(fd, 0) |
| + defer { close(fd) } |
| + 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<sockaddr_in>.size) |
| + let listening = withUnsafeMutablePointer(to: &address) { |
| + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { pointer -> Int32 in |
| + guard Darwin.bind(fd, pointer, length) == 0, listen(fd, 8) == 0 else { return -1 } |
| + return getsockname(fd, pointer, &length) |
| + } |
| + } |
| + XCTAssertEqual(listening, 0) |
| + let outcomes = Outcomes() |
| + let client = NativeHTTP(origin: URL(string: "http://127.0.0.1:\(UInt16(bigEndian: address.sin_port))")!, |
| + observer: { outcomes.append($0) }) |
| + for call in ["responseDetails", "stream", "download"] { |
| + let task = Task { |
| + switch call { |
| + case "responseDetails": _ = try await client.responseDetails("/silent") |
| + case "stream": try await client.stream("/silent") { _ in } |
| + default: try await client.download("/silent", to: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) |
| + } |
| + } |
| + try await Task.sleep(nanoseconds: 200_000_000) |
| + task.cancel() |
| + do { |
| + try await task.value |
| + XCTFail("\(call): a cancelled request completed") |
| + } catch { XCTAssertTrue(error is CancellationError, "\(call): expected CancellationError, got \(error)") } |
| + } |
| + XCTAssertEqual(outcomes.values, [], "Cancellation is not evidence about connectivity") |
| + } |
| + |
| + func testOfflineClassification() { |
| + let offline: [URLError.Code] = [.notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, |
| + .dnsLookupFailed, .timedOut, .dataNotAllowed, .internationalRoamingOff] |
| + for code in offline { |
| + XCTAssertEqual(NativeClientError.classify(URLError(code)) as? NativeClientError, .offline(code)) |
| + } |
| + // A TLS failure is a real fault. Calling it offline would hide it. |
| + for code in [URLError.Code.secureConnectionFailed, .serverCertificateUntrusted, .serverCertificateHasBadDate, |
| + .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot] { |
| + let typed = NativeClientError.classify(URLError(code)) as? NativeClientError |
| + XCTAssertEqual(typed, .transport(code)) |
| + XCTAssertEqual(typed?.retryClass, .transient) |
| + } |
| + XCTAssertTrue(NativeClientError.classify(URLError(.cancelled)) is CancellationError) |
| + XCTAssertTrue(NativeClientError.classify(CancellationError()) is CancellationError) |
| + } |
| + |
| + func testRetryAfterAcceptsSecondsAndHTTPDate() { |
| + let now = Date(timeIntervalSince1970: 1_790_000_000) |
| + XCTAssertEqual(NativeClientError.retryAfter("120", now: now), 120) |
| + let formatter = DateFormatter() |
| + formatter.locale = Locale(identifier: "en_US_POSIX") |
| + formatter.timeZone = TimeZone(identifier: "GMT") |
| + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" |
| + XCTAssertEqual(NativeClientError.retryAfter(formatter.string(from: now.addingTimeInterval(30)), now: now), 30) |
| + XCTAssertEqual(NativeClientError.retryAfter(formatter.string(from: now.addingTimeInterval(-30)), now: now), 0) |
| + XCTAssertNil(NativeClientError.retryAfter("soon", now: now)) |
| + XCTAssertNil(NativeClientError.retryAfter(nil, now: now)) |
| + } |
| + |
| + /// Callers that show `localizedDescription` read what they read before. |
| + func testDescriptionsAreTodaysStrings() { |
| + XCTAssertEqual(NativeClientError.unauthorized.localizedDescription, "Your session has ended. Sign out and sign in again.") |
| + XCTAssertEqual(NativeClientError.notFound.localizedDescription, "This server does not support this native feature yet.") |
| + XCTAssertEqual(NativeClientError.server(status: 500).localizedDescription, |
| + "The request could not be completed (500). Your changes have not been confirmed.") |
| + XCTAssertEqual(NativeClientError.message("Invalid server path.").localizedDescription, "Invalid server path.") |
| + } |
| +} |
diff --git a/Tests/RastrilloTestSupportTests/ScriptedTransportTests.swift b/Tests/RastrilloTestSupportTests/ScriptedTransportTests.swift| new file mode 100644 |
| index 0000000..62b8585 |
| --- /dev/null |
| +++ b/Tests/RastrilloTestSupportTests/ScriptedTransportTests.swift |
| @@ -0,0 +1,177 @@ |
| +import Foundation |
| +import XCTest |
| +import RastrilloApple |
| +import RastrilloTestSupport |
| + |
| +private struct Empty: Decodable {} |
| + |
| +private actor Lines { |
| + var values: [String] = [] |
| + func append(_ value: String) { values.append(value) } |
| +} |
| + |
| +/// Each error class a consumer must tell apart, produced the way an app |
| +/// meets it: through `request`, `stream` and `download` on a transport. |
| +final class ScriptedTransportTests: XCTestCase { |
| + private func requestFailure(_ step: ScriptedTransport.Step, |
| + file: StaticString = #filePath, line: UInt = #line) async -> Error? { |
| + let transport = ScriptedTransport([step]) |
| + do { |
| + let _: Empty = try await transport.request("/x", method: "POST", body: Data("{}".utf8)) |
| + XCTFail("The scripted step succeeded", file: file, line: line) |
| + return nil |
| + } catch { return error } |
| + } |
| + |
| + private func assertRequest(_ step: ScriptedTransport.Step, fails expected: NativeClientError, |
| + _ retry: NativeClientError.RetryClass, |
| + file: StaticString = #filePath, line: UInt = #line) async { |
| + let error = await requestFailure(step, file: file, line: line) |
| + guard let typed = error as? NativeClientError else { |
| + return XCTFail("Expected \(expected), got \(String(describing: error))", file: file, line: line) |
| + } |
| + XCTAssertEqual(typed, expected, file: file, line: line) |
| + XCTAssertEqual(typed.retryClass, retry, file: file, line: line) |
| + } |
| + |
| + func testOffline() async { |
| + await assertRequest(.offline(), fails: .offline(.notConnectedToInternet), .offline) |
| + await assertRequest(.offline(.timedOut), fails: .offline(.timedOut), .offline) |
| + } |
| + |
| + func testEndedSession() async { |
| + await assertRequest(.respond(status: 401), fails: .unauthorized, .auth) |
| + // NoRedirects surfaces the redirect to /signin as a 303. |
| + await assertRequest(.respond(status: 303, headers: ["Location": "/signin"]), fails: .unauthorized, .auth) |
| + } |
| + |
| + func testPermanentRefusals() async { |
| + await assertRequest(.respond(status: 403), fails: .forbidden, .permanent) |
| + await assertRequest(.respond(status: 404), fails: .notFound, .permanent) |
| + await assertRequest(.respond(status: 413), fails: .tooLarge, .permanent) |
| + await assertRequest(.respond(status: 400, body: Data("bad".utf8)), |
| + fails: .rejected(status: 400, body: Data("bad".utf8)), .permanent) |
| + await assertRequest(.respond(status: 422), fails: .rejected(status: 422, body: Data()), .permanent) |
| + } |
| + |
| + func testConflictKeepsTheServersBody() async { |
| + let theirs = Data(#"{"value":"theirs"}"#.utf8) |
| + await assertRequest(.respond(status: 409, body: theirs), fails: .conflict(theirs), .conflict) |
| + } |
| + |
| + func testRateLimitedCarriesRetryAfter() async { |
| + await assertRequest(.respond(status: 429, headers: ["Retry-After": "7"]), fails: .rateLimited(retryAfter: 7), .transient) |
| + await assertRequest(.respond(status: 429), fails: .rateLimited(retryAfter: nil), .transient) |
| + // A 503 that says when to come back is a rate limit, not a fault. |
| + await assertRequest(.respond(status: 503, headers: ["retry-after": "3"]), fails: .rateLimited(retryAfter: 3), .transient) |
| + } |
| + |
| + func testServerFaults() async { |
| + await assertRequest(.respond(status: 500), fails: .server(status: 500), .transient) |
| + await assertRequest(.respond(status: 503), fails: .server(status: 503), .transient) |
| + } |
| + |
| + func testSuccessDecodesAndIsCommitted() async throws { |
| + let transport = ScriptedTransport([.respond(status: 201, body: Data("{}".utf8))]) |
| + let _: Empty = try await transport.request("/notes", method: "POST", body: Data("{}".utf8)) |
| + XCTAssertEqual(transport.committed.map(\.path), ["/notes"]) |
| + XCTAssertEqual(transport.remainingSteps, 0) |
| + } |
| + |
| + func testResponseDetailsReturnsAnyStatus() async throws { |
| + let transport = ScriptedTransport([.respond(status: 401, headers: ["Docs-Seq": "4"])]) |
| + let response = try await transport.responseDetails("/d/1") |
| + XCTAssertEqual(response.status, 401) |
| + XCTAssertEqual(response.header("docs-seq"), "4") |
| + XCTAssertThrowsError(try response.requireSuccess()) { XCTAssertEqual($0 as? NativeClientError, .unauthorized) } |
| + XCTAssertTrue(transport.committed.isEmpty, "A refused request was recorded as applied") |
| + } |
| + |
| + func testCommitThenLoseResponse() async { |
| + let transport = ScriptedTransport([.commitThenLoseResponse()]) |
| + do { |
| + _ = try await transport.responseDetails("/d/1/updates", method: "POST", body: Data([1, 2])) |
| + XCTFail("A lost response succeeded") |
| + } catch { |
| + XCTAssertEqual(error as? NativeClientError, .offline(.networkConnectionLost)) |
| + XCTAssertEqual((error as? NativeClientError)?.retryClass, .offline) |
| + } |
| + // The caller saw a failure, yet the server holds the write: the case a |
| + // durable outbox must not send twice in effect. |
| + XCTAssertEqual(transport.committed, [.init(kind: .request, path: "/d/1/updates", method: "POST", body: Data([1, 2]))]) |
| + } |
| + |
| + func testHangUntilCancelled() async throws { |
| + let transport = ScriptedTransport([.hang]) |
| + let task = Task { try await transport.responseDetails("/slow") } |
| + while transport.calls.isEmpty { try await Task.sleep(nanoseconds: 1_000_000) } |
| + task.cancel() |
| + do { |
| + _ = try await task.value |
| + XCTFail("A hung request completed") |
| + } catch { XCTAssertTrue(error is CancellationError, "Expected cancellation, got \(error)") } |
| + } |
| + |
| + func testRefusedStreamIsTyped() async { |
| + let transport = ScriptedTransport([.respond(status: 401), .offline()]) |
| + do { |
| + try await transport.stream("/d/1/stream") { _ in XCTFail("A refused stream delivered a line") } |
| + XCTFail("A refused stream opened") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) } |
| + do { |
| + try await transport.stream("/d/1/stream", onOpen: { XCTFail("An offline stream opened") }) { _ in } |
| + XCTFail("An offline stream opened") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .offline(.notConnectedToInternet)) } |
| + } |
| + |
| + func testStreamOpensBeforeLines() async throws { |
| + let transport = ScriptedTransport([.respond(status: 200, body: Data("event: update\ndata: 42\n\n".utf8))]) |
| + let lines = Lines() |
| + try await transport.stream("/d/1/stream", onOpen: { await lines.append("opened") }) { await lines.append($0) } |
| + let received = await lines.values |
| + XCTAssertEqual(received, ["opened", "event: update", "data: 42", ""]) |
| + } |
| + |
| + func testDownloadWritesOrFailsTyped() async throws { |
| + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) |
| + defer { try? FileManager.default.removeItem(at: directory) } |
| + let transport = ScriptedTransport([.respond(status: 200, body: Data("12345".utf8)), .respond(status: 403)]) |
| + let file = directory.appendingPathComponent("export.xlsx") |
| + try await transport.download("/export", to: file) |
| + XCTAssertEqual(try Data(contentsOf: file), Data("12345".utf8)) |
| + do { |
| + try await transport.download("/export", to: directory.appendingPathComponent("again.xlsx")) |
| + XCTFail("A refused export succeeded") |
| + } catch { XCTAssertEqual(error as? NativeClientError, .forbidden) } |
| + } |
| + |
| + func testUnscriptedCallFailsLoudly() async { |
| + let transport = ScriptedTransport() |
| + do { |
| + _ = try await transport.responseDetails("/surprise") |
| + XCTFail("An unscripted call succeeded") |
| + } catch { XCTAssertTrue(error is ScriptedTransport.Unscripted, "Got \(error)") } |
| + XCTAssertEqual(transport.calls.map(\.path), ["/surprise"]) |
| + } |
| + |
| + func testResponderAnswersRacingCallsByWhatTheyAsk() async throws { |
| + let transport = ScriptedTransport { call in |
| + switch (call.kind, call.path) { |
| + case (.stream, "/d/1/stream"): return .hang |
| + case (.request, "/d/1/updates"): return .respond(status: 204) |
| + default: return nil |
| + } |
| + } |
| + let listening = Task { try await transport.stream("/d/1/stream") { _ in } } |
| + let saved = try await transport.responseDetails("/d/1/updates", method: "POST", body: Data([7])) |
| + XCTAssertEqual(saved.status, 204) |
| + XCTAssertEqual(transport.committed.map(\.path), ["/d/1/updates"]) |
| + do { |
| + _ = try await transport.responseDetails("/elsewhere") |
| + XCTFail("A declined call succeeded") |
| + } catch { XCTAssertTrue(error is ScriptedTransport.Unscripted, "Got \(error)") } |
| + listening.cancel() |
| + do { try await listening.value; XCTFail("A hung stream completed") } catch { XCTAssertTrue(error is CancellationError) } |
| + } |
| +} |
diff --git a/Tests/RastrilloTestSupportTests/StreamParityTests.swift b/Tests/RastrilloTestSupportTests/StreamParityTests.swift| new file mode 100644 |
| index 0000000..432a17c |
| --- /dev/null |
| +++ b/Tests/RastrilloTestSupportTests/StreamParityTests.swift |
| @@ -0,0 +1,97 @@ |
| +#if os(macOS) |
| +import Foundation |
| +import XCTest |
| +import RastrilloApple |
| +import RastrilloTestSupport |
| + |
| +private actor Collected { |
| + var lines: [String] = [] |
| + func append(_ line: String) { lines.append(line) } |
| +} |
| + |
| +/// A scripted stream must deliver exactly what a real one does for the same |
| +/// bytes, or a test passes against framing no server produces. |
| +final class StreamParityTests: XCTestCase { |
| + private let bodies: [String: Data] = [ |
| + "lf": Data("event: update\ndata: 1\n\n".utf8), |
| + "crlf": Data("event: update\r\ndata: 2\r\n\r\n".utf8), |
| + "cr": Data("event: update\rdata: 3\r\r".utf8), |
| + "mixed": Data("a\r\nb\rc\n\r\n".utf8), |
| + "unterminated": Data("data: 4\n\ndata: partial".utf8), |
| + "bom": Data("\u{FEFF}data: 5\n\n".utf8), |
| + ] |
| + |
| + private func server(_ bodies: [String: Data]) throws -> (Process, URL) { |
| + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
| + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) |
| + for (name, body) in bodies { try body.write(to: directory.appendingPathComponent(name)) } |
| + let process = Process() |
| + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") |
| + process.arguments = ["-u", "-c", #""" |
| +import http.server, pathlib, sys |
| +root = pathlib.Path(sys.argv[1]) |
| +class Handler(http.server.BaseHTTPRequestHandler): |
| + def log_message(self, *args): pass |
| + def do_GET(self): |
| + body = (root / self.path.strip('/')).read_bytes() |
| + self.send_response(200) |
| + self.send_header('Content-Type', 'text/event-stream') |
| + self.send_header('Content-Length', str(len(body))) |
| + self.end_headers() |
| + self.wfile.write(body) |
| +server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) |
| +print(server.server_port, flush=True) |
| +server.serve_forever() |
| +"""#, directory.path] |
| + let output = Pipe() |
| + process.standardOutput = output |
| + try process.run() |
| + var port = Data() |
| + while port.count < 12 { |
| + let byte = output.fileHandleForReading.readData(ofLength: 1) |
| + if byte.isEmpty || byte == Data([10]) { break } |
| + port.append(byte) |
| + } |
| + guard let number = String(data: port, encoding: .utf8).flatMap(Int.init), |
| + let origin = URL(string: "http://127.0.0.1:\(number)") else { |
| + process.terminate() |
| + throw NativeClientError.message("The parity fixture could not start.") |
| + } |
| + return (process, origin) |
| + } |
| + |
| + private func lines(_ transport: NativeTransport, _ path: String) async throws -> [String] { |
| + let collected = Collected() |
| + try await transport.stream(path) { await collected.append($0) } |
| + return await collected.lines |
| + } |
| + |
| + func testScriptedStreamFramesLikeNativeHTTP() async throws { |
| + let (process, origin) = try server(bodies) |
| + defer { process.terminate() } |
| + let real = NativeHTTP(origin: origin) |
| + for (name, body) in bodies.sorted(by: { $0.key < $1.key }) { |
| + let expected = try await lines(real, "/\(name)") |
| + let scripted = try await lines(ScriptedTransport([.respond(status: 200, body: body)]), "/\(name)") |
| + XCTAssertEqual(scripted, expected, "\(name): scripted framing differs from NativeHTTP") |
| + } |
| + } |
| + |
| + func testScriptedStreamRefusesWhatNativeHTTPRefuses() async throws { |
| + let invalid = Data([0x64, 0x61, 0x74, 0x61, 0x3A, 0xFF, 0x0A]) |
| + let long = Data(repeating: 0x61, count: 256 * 1024 + 1) + Data("\n".utf8) |
| + let (process, origin) = try server(["invalid": invalid, "long": long]) |
| + defer { process.terminate() } |
| + let real = NativeHTTP(origin: origin) |
| + for (name, body) in [("invalid", invalid), ("long", long)] { |
| + var realError: String? |
| + do { _ = try await lines(real, "/\(name)") } catch { realError = error.localizedDescription } |
| + XCTAssertNotNil(realError, "\(name): NativeHTTP accepted it") |
| + do { |
| + _ = try await lines(ScriptedTransport([.respond(status: 200, body: body)]), "/\(name)") |
| + XCTFail("\(name): ScriptedTransport accepted what NativeHTTP refuses") |
| + } catch { XCTAssertEqual(error.localizedDescription, realError, name) } |
| + } |
| + } |
| +} |
| +#endif |