rastrillo / native Public

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>
claude agent, operated by paul@keymail.dev 2986f5186d6ebdd4e72c42afd66b8316d81c0746 parent 2a5d07bparent 4ac16d5
13 files changed, +910 −46
  • Package.swift +4 −1
  • README.md +31 −0
  • Sources/RastrilloApple/NativeClientError.swift +122 −0
  • Sources/RastrilloApple/NativeHTTP.swift +57 −40
  • Sources/RastrilloApple/NativeResponse.swift +20 −0
  • Sources/RastrilloApple/NativeStreamLines.swift +4 −3
  • Sources/RastrilloApple/NativeTransport.swift +63 −0
  • Sources/RastrilloTestSupport/ScriptedTransport.swift +146 −0
  • Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift +2 −0
  • Tests/RastrilloAppleTests/HTTPTests.swift +47 −2
  • Tests/RastrilloAppleTests/TypedErrorTests.swift +140 −0
  • Tests/RastrilloTestSupportTests/ScriptedTransportTests.swift +177 −0
  • Tests/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